Compare commits

...

88 Commits

Author SHA1 Message Date
1fab92f0e5 Add Codex operations guide 2026-08-30 21:32:59 +00:00
decb975255 Stabilise story intelligence review rendering 2026-08-30 21:12:30 +00:00
d6d52b09e3 Consolidate story intelligence review module loads 2026-08-30 20:46:31 +00:00
a1cba8a057 Speed up review centre summary 2026-08-30 20:08:17 +00:00
5a7b8c79c7 Fix independent review centre state 2026-08-30 19:50:15 +00:00
794606f723 Fix structural Word paragraph mapping 2026-08-29 21:42:09 +00:00
845b42e5fe Fix trailing scene boundary normalisation 2026-08-29 21:25:28 +00:00
d32de17dcc Chunk Word Companion manuscript scans 2026-08-29 21:07:00 +00:00
d2449a4683 Fix story intelligence review regressions 2026-08-29 20:41:38 +00:00
99d818ed9f Preserve author plot seed provenance 2026-08-29 20:25:40 +00:00
80d13c6a7f Fix Story Intelligence global progress reconnect 2026-08-29 20:00:05 +00:00
b639b12163 Implement whole-book plot intelligence 2026-08-29 18:57:46 +00:00
85286428c2 Correct relationship intelligence classification 2026-08-29 14:14:34 +00:00
feed4549bf Order relationship review by first appearance 2026-08-29 13:23:52 +00:00
ae0552af90 Generalise Story Intelligence entity consolidation 2026-08-29 12:45:04 +00:00
b7f24954e1 Consolidate Story Intelligence location candidates 2026-08-29 12:24:45 +00:00
6dec8a4345 Stabilise Story Intelligence materialisation 2026-08-29 11:52:52 +00:00
78f401b417 Clean up Story Intelligence review module chrome 2026-08-29 10:27:47 +00:00
dd4b0badb6 Redesign Story Intelligence Review Centre as tile grid 2026-08-29 09:56:32 +00:00
730537daee Fix Review Centre view resolution 2026-08-29 09:41:55 +00:00
0b74ee9ec0 Add persistent Story Intelligence review centre route 2026-08-29 09:36:02 +00:00
0ec0167746 Remove obsolete Story Intelligence scene review workflow 2026-08-29 09:33:14 +00:00
e9f15e076b Complete Story Intelligence scene analysis pipeline 2026-08-29 09:10:38 +00:00
a28495865a Fix live Story Intelligence progress updates 2026-08-28 22:37:07 +00:00
9c2a3215c5 Use strict structured scene intelligence output 2026-08-28 21:56:24 +00:00
6d367b8977 Stabilise story intelligence global progress 2026-08-28 20:42:02 +00:00
a6ca129f4d Simplify active story intelligence navigation 2026-08-28 20:30:50 +00:00
a319df2455 Keep recovered AI failures out of user warnings 2026-08-28 20:14:02 +00:00
0cc25c570c Fix story intelligence progress totals 2026-08-28 19:47:05 +00:00
96343138ea Add concurrent story intelligence execution 2026-08-28 19:21:31 +00:00
6d2340ae46 Add story intelligence review centre 2026-08-28 16:10:42 +00:00
90d8f65fcf Improve story intelligence narrative hierarchy 2026-08-28 15:46:16 +00:00
9c27ba6ffa Complete story intelligence entity quality proof 2026-08-28 15:13:47 +00:00
3bf8f88f16 Tighten structural asset phrase extraction 2026-08-28 14:03:10 +00:00
6aea9736d4 Improve story intelligence entity quality 2026-08-28 13:58:29 +00:00
108df3db3e Add canonical story intelligence scene context 2026-08-28 13:18:19 +00:00
58075232e7 Fix structural POV resolution lifecycle 2026-08-28 12:41:44 +00:00
caa70be9f4 Add POV and character identity foundation 2026-08-28 11:29:49 +00:00
1f5ab018b0 Add Story Intelligence refactor plan document 2026-08-28 11:04:14 +00:00
ed4ad31466 Fix Story Intelligence progress scene context 2026-08-27 20:11:24 +00:00
a2cf6a96a8 Repair partial scene import retry 2026-08-27 19:44:21 +00:00
4f82dc308a Streamline core import wizard steps 2026-08-27 19:32:27 +00:00
ea41ec3aac Add deterministic manuscript text statistics 2026-08-27 19:03:54 +00:00
7d33323de7 Fix Story Intelligence character review context 2026-08-27 18:37:10 +00:00
dabf63d91d Simplify import wizard and add book context 2026-08-27 16:02:47 +00:00
358913f0ef Unify core import wizard and scene titles 2026-08-27 15:27:12 +00:00
c10745bcc4 Use structural summaries in Story Intelligence review 2026-08-27 14:58:16 +00:00
b6aa53ee12 Refactor Story Intelligence to use canonical scenes 2026-08-26 21:12:06 +00:00
dc5232378f Fix permanent project hard delete dependencies 2026-08-26 19:19:34 +00:00
3740658f3e Fix core import scene follow and character evidence 2026-08-26 19:05:27 +00:00
f76e5bc388 Add explicit Word save confirmation for import 2026-08-26 13:00:47 +00:00
e0c3171329 Fix hard delete for manuscript source rows 2026-08-26 12:35:29 +00:00
5a38560f47 Fix onboarding Word marker save acknowledgement 2026-08-26 08:54:23 +00:00
a6c870ab6d Fix onboarding first scene build procedure 2026-08-25 21:11:18 +00:00
45aa1c7384 Fix structural JSON numeric repair 2026-08-25 20:56:56 +00:00
fbb973574c Enforce staged core import scan state 2026-08-25 20:25:36 +00:00
4214779883 Fix core import scene detection flow 2026-08-25 19:40:54 +00:00
24f2120cb2 Fix onboarding import wizard sequencing 2026-08-25 18:55:51 +00:00
c8148245c9 Fix Story Intelligence knowledge recovery transition 2026-08-25 15:38:48 +00:00
1b8b2a984a Verify AI-discovered Plot Line candidates 2026-08-25 15:13:33 +00:00
a6145a25e8 Add Story Intelligence narrative review stage 2026-08-25 14:49:19 +00:00
398579c5c9 Restore Word Companion existing book linking 2026-08-25 14:12:57 +00:00
9d8d443067 Improve core import character mapping 2026-08-25 13:14:16 +00:00
0e75a95625 Persist scene source and correct import order 2026-08-25 12:55:22 +00:00
998c301d65 Enrich existing structural scenes with Story Intelligence 2026-08-25 12:35:07 +00:00
adf93a075c Require Word marker save acknowledgement 2026-08-25 12:35:00 +00:00
fbe8e60d59 Add structural manuscript import phase 2026-08-25 12:34:51 +00:00
d62e825a17 Correct premature Story Intelligence safe-close wording 2026-08-25 09:00:10 +00:00
3b2b25eeab Fix admin reset and project hard delete 2026-08-24 21:19:59 +00:00
c2a6dbd849 Fix Word Companion writing screen handoff 2026-08-24 20:53:43 +00:00
a90b98f8f0 Improve post-import review defaults 2026-08-24 20:29:28 +00:00
1dfc620e00 Fix character review defaults 2026-08-24 19:12:51 +00:00
2bb5decf63 Remove Story Intelligence visualisation runtime 2026-08-24 14:47:29 +00:00
25a8ce65fa Add name intelligence and generation safeguards 2026-08-24 12:01:07 +00:00
77369a86fd Improve writing workspace display selection 2026-08-17 20:53:39 +00:00
1c7cf9bee1 Keep character review within one wizard step 2026-08-17 20:12:43 +00:00
1ead298fa8 Hide discarded scan character candidates 2026-08-17 19:47:56 +00:00
09f003b0cd Tighten single-word character candidate filtering 2026-08-17 19:33:38 +00:00
95f5c2925d Tighten manuscript character detection 2026-08-17 19:21:56 +00:00
43a57df070 Add writing workspace launcher 2026-08-17 19:09:42 +00:00
451e384966 Add safe Word manuscript cleanup 2026-08-17 15:28:09 +00:00
884a96d166 Add paired writing intent follow screen 2026-08-17 14:59:41 +00:00
fa2ffe4f17 Test writing context follow contract 2026-08-17 14:34:13 +00:00
bba75f3ac4 Add writing context route and live display 2026-08-17 14:34:08 +00:00
90307757b4 Unify browser story position follow events 2026-08-17 14:17:29 +00:00
9f0fdbc366 Establish canonical story position context 2026-08-17 14:17:19 +00:00
f6b4263f31 Improve scan coverage and visual matching 2026-08-17 11:06:04 +00:00
c96c20d18b Use AI for scan character sex hints 2026-08-16 20:51:12 +00:00
274 changed files with 130258 additions and 7130 deletions

166
AGENTS.md Normal file
View File

@ -0,0 +1,166 @@
# PlotDirector Codex Operations Guide
This repository is the PlotDirector ASP.NET Core application. Use this file as the first stop for operational knowledge that is not obvious from source.
## Environment Architecture
- Main repo path: `/srv/repos/PlotDirector`.
- Web app project: `PlotLine/PlotLine.csproj`.
- Test project: `PlotLine.Tests/PlotLine.Tests.csproj`.
- Solution file: `PlotLine.slnx`.
- Word add-in project: `PlotDirector.WordCompanion/`.
- Development site host files: `/srv/apps/plotdirector-dev/current`.
- Development release archive: `/srv/apps/plotdirector-dev/releases/`.
- Development systemd service: `plotdirector-dev`.
- Service working directory: `/srv/apps/plotdirector-dev/current`.
- Service environment file: `/etc/plotdirector/plotdirector.env`.
- The dev app listens locally on `http://127.0.0.1:5050` behind nginx as `https://dev.plotdirector.com/`.
## Database
- Production-style dev database name: `PlotDirector_Development`.
- Older/local default database name in appsettings may be `PlotLine`; do not assume that is the effective dev database.
- Runtime connection details come from `/etc/plotdirector/plotdirector.env`, key `ConnectionStrings__DefaultConnection`.
- Do not print, copy, or commit connection strings, passwords, API keys, SMTP secrets, Stripe secrets, or OpenAI keys.
- The SQL Server is reachable from the app host using the environment-file connection string. If a one-off SQL runner is needed, read the env file safely without shell-evaluating values:
```bash
conn=""
while IFS= read -r line; do
case "$line" in
ConnectionStrings__DefaultConnection=*) conn="${line#ConnectionStrings__DefaultConnection=}" ;;
esac
done < /etc/plotdirector/plotdirector.env
export PLOT_DIRECTOR_SQL_CONNECTION="$conn"
```
- Do not use `source /etc/plotdirector/plotdirector.env`; unquoted values with spaces or special characters can break the shell and leak confusion into the task.
- Use stored procedures for application database access. Do not add inline SQL in application code.
- Old SQL scripts are immutable. Never edit an existing numbered script to fix a live schema issue; add a new forward-only script.
- Before restarting/deploying during Story Intelligence work, check there are no active runs in `StoryIntelligenceRuns`, `StoryIntelligenceJobs`, or `WholeBookPlotSynthesisRuns`.
- Do not restore, reset, or overwrite `PlotDirector_Development` unless the user explicitly asks and the target backup has been verified. Restore backups only to disposable verification databases first.
## SQL Migration Procedure
- Number new scripts under `PlotLine/Sql/` using the next available prefix, for example `175_...sql`.
- Scripts should be idempotent where practical: guard table/column/index creation with existence checks and use `CREATE OR ALTER PROCEDURE`.
- Apply database changes to dev deliberately and capture the script path in the final response.
- After applying SQL, run a smoke query that confirms the expected tables/procedures/columns exist and that the connection reports `PlotDirector_Development`.
- Permanent delete paths have historically exposed missing cascade/cleanup dependencies. When adding tables that reference projects, books, chapters, scenes, characters, candidates, or runs, design deletion behaviour intentionally.
## Build And Test
- Full test gate:
```bash
dotnet run --project PlotLine.Tests/PlotLine.Tests.csproj
```
- Build gate:
```bash
dotnet build PlotLine.slnx
```
- Publish gate:
```bash
dotnet publish PlotLine/PlotLine.csproj -c Release -o /tmp/plotdirector-dev-publish-<commit>-<timestamp>
```
- Known quirk: running build and tests in parallel can cause static web asset cache file locks, especially around `*.staticwebassets*.json` or `rjsmrazor.dswa.cache.json`. Run build/test serially when verifying.
- Prefer `rg`/`rg --files` for repository searches.
## Running Locally
- From `/srv/repos/PlotDirector`, run:
```bash
dotnet run --project PlotLine/PlotLine.csproj
```
- For local database/API work, provide configuration through environment variables, user secrets, or a safe local appsettings override. Do not rely on secrets committed in appsettings, and do not add new secrets to source.
- The deployed dev service uses `/etc/plotdirector/plotdirector.env`; local terminal runs do not automatically use that file unless you explicitly provide equivalent environment variables.
## Development Deployment
Use the existing physical-directory deployment pattern. `current` is a real directory, not a symlink.
1. Confirm the commit to deploy:
```bash
git status --short --branch
git log -1 --oneline
```
2. Confirm no active Story Intelligence work is running before restart.
3. Publish to a fresh temp folder:
```bash
release="<shortsha>-$(date -u +%Y%m%d%H%M%S)"
publish_dir="/tmp/plotdirector-dev-publish-$release"
release_dir="/srv/apps/plotdirector-dev/releases/$release"
backup_dir="/srv/apps/plotdirector-dev/current-before-$release"
dotnet publish PlotLine/PlotLine.csproj -c Release -o "$publish_dir"
```
4. Install the release:
```bash
sudo mkdir -p "$release_dir"
sudo cp -a "$publish_dir"/. "$release_dir"/
sudo chown -R plotdirector:plotdirector "$release_dir"
sudo cp -a /srv/apps/plotdirector-dev/current "$backup_dir"
sudo chown -R plotdirector:plotdirector "$backup_dir"
sudo rsync -a --delete "$release_dir"/ /srv/apps/plotdirector-dev/current/
sudo chown -R plotdirector:plotdirector /srv/apps/plotdirector-dev/current
sudo systemctl restart plotdirector-dev
```
5. Verify:
```bash
systemctl is-active plotdirector-dev
systemctl status plotdirector-dev --no-pager -n 30
curl -k -L --max-time 20 -o /tmp/pd-dev-home.html -w 'http_code=%{http_code}\nfinal_url=%{url_effective}\nbytes=%{size_download}\n' https://dev.plotdirector.com/
sudo stat -c 'CurrentDll=%n %y %s bytes' /srv/apps/plotdirector-dev/current/PlotLine.dll
```
- A `HEAD` request to the dev site may return `405`; use `GET` with `curl -L` for the real smoke check.
- Keep the release folder and current-before backup named with the deployed short SHA and UTC timestamp for traceability.
## Project Structure
- `PlotLine/Controllers/`: MVC controllers.
- `PlotLine/Views/`: Razor views.
- `PlotLine/Services/`: application services, Story Intelligence workers, OpenAI integration, import/onboarding flow.
- `PlotLine/Data/`: repositories and database access.
- `PlotLine/Models/`: domain and DTO models.
- `PlotLine/ViewModels/`: UI view models.
- `PlotLine/Hubs/`: SignalR hubs.
- `PlotLine/wwwroot/`: static CSS/JS/assets.
- `PlotLine/Sql/`: forward-only SQL migration scripts and stored procedure definitions.
- `PlotLine.Tests/Program.cs`: lightweight integration/regression test harness.
- `docs/` and `Docs/`: project documentation stores; check both before creating new documentation.
## Story Intelligence Conventions
- PlotDirector must work for arbitrary books and authors. Do not add production special cases for Alpha Flame or any other manuscript-specific names, places, vehicles, objects, or relationships.
- Manuscript-specific examples may appear in regression fixtures only, and tests should include unrelated examples so production code cannot pass by recognising one fixture.
- Use generic evidence for significance: recurrence, scene/chapter presence, ownership, narrative prominence, title presence, knowledge significance, and participation in important events.
- Relationship classification must use generic interaction evidence and configured relationship types, not character-specific rules.
- Character/alias resolution should use canonical identities, aliases, and evidence rather than book-specific names.
- Scene manuscript text in `SceneManuscriptSources` is the durable source for canonical scene analysis.
- Core import and Story Intelligence are separate phases. Do not reintroduce the old visualisation/replay workflow as part of production import.
- Whole-book plot synthesis should happen after character resolution and should not add another per-scene AI pass.
- If prompt/source text is too large, fail the specific synthesis job clearly rather than silently truncating manuscript text.
## Operational Safety
- Avoid destructive git commands such as `git reset --hard` or `git checkout --` unless the user explicitly asks.
- The worktree may include user changes. Inspect before editing and do not revert unrelated changes.
- Do not deploy while an import or Story Intelligence analysis is active unless the user explicitly accepts the interruption.
- Do not modify current database state for read-only investigation tasks.
- Do not apply SQL during read-only audit tasks.
- Use disposable verification databases for restore checks and drop them afterwards unless instructed otherwise.
- Keep deployment and SQL notes concise in final responses: commit, SQL script, tests, deployed release, service status, and any residual risks.

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,9 @@ public sealed class AdminController(
IStoryIntelligenceExistingChapterQueueService existingChapterQueue, IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
IStoryIntelligenceFullChapterTestService fullChapterTest, IStoryIntelligenceFullChapterTestService fullChapterTest,
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit, IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
IIllustrationLibraryService illustrationLibrary) : Controller INameIntelligenceService nameIntelligence,
IIllustrationLibraryService illustrationLibrary,
ILogger<AdminController> logger) : Controller
{ {
[HttpGet("")] [HttpGet("")]
[HttpGet("index")] [HttpGet("index")]
@ -39,6 +41,34 @@ 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)
{ {
@ -169,8 +199,27 @@ 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));
} }

View File

@ -69,6 +69,8 @@ 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;

View File

@ -51,6 +51,7 @@ 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);
@ -59,6 +60,15 @@ 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)

View File

@ -32,6 +32,12 @@ 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);
@ -99,6 +105,35 @@ 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)

View File

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

View File

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

View File

@ -3,12 +3,18 @@ using Microsoft.AspNetCore.Mvc;
using PlotLine.Models; using PlotLine.Models;
using PlotLine.Services; using PlotLine.Services;
using PlotLine.ViewModels; using PlotLine.ViewModels;
using System.Diagnostics;
namespace PlotLine.Controllers; namespace PlotLine.Controllers;
[Authorize] [Authorize]
[Route("onboarding")] [Route("onboarding")]
public sealed class OnboardingController(IOnboardingService onboarding, IOnboardingStoryIntelligenceService storyIntelligence) : Controller public sealed class OnboardingController(
IOnboardingService onboarding,
IOnboardingStoryIntelligenceService storyIntelligence,
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
ICurrentUserService currentUser,
ILogger<OnboardingController> logger) : Controller
{ {
[HttpGet("")] [HttpGet("")]
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
@ -60,6 +66,14 @@ 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);
@ -76,18 +90,31 @@ 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();
return View(model); if (model.ExistingBatchID.HasValue)
{
return RedirectToBook(model);
}
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()
{ {
var model = await storyIntelligence.GetOverviewAsync(); return await StoryIntelligence();
return View(model);
} }
[HttpGet("story-intelligence/book/{bookId:int}/continue")] [HttpGet("story-intelligence/book/{bookId:int}/continue")]
public async Task<IActionResult> ContinueStoryIntelligence(int bookId) public async Task<IActionResult> ContinueStoryIntelligence(int bookId)
=> await StoryIntelligenceBookReviewCentre(bookId);
[HttpGet("story-intelligence/book/{bookId:int}/review-centre")]
public async Task<IActionResult> StoryIntelligenceBookReviewCentre(int bookId)
{ {
var target = await storyIntelligence.ResumeBookAsync(bookId); var target = await storyIntelligence.ResumeBookAsync(bookId);
if (target is null) if (target is null)
@ -98,32 +125,117 @@ 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.ReviewCentre => await StoryIntelligenceReviewCentre(target.BatchID),
StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Complete => await StoryIntelligenceReviewCentre(target.BatchID),
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Narrative => await StoryIntelligenceReviewCentre(target.BatchID),
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Knowledge => await StoryIntelligenceReviewCentre(target.BatchID),
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Relationships => await StoryIntelligenceReviewCentre(target.BatchID),
StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Assets => await StoryIntelligenceReviewCentre(target.BatchID),
_ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID }) StoryIntelligenceResumeRoutes.Locations => await StoryIntelligenceReviewCentre(target.BatchID),
StoryIntelligenceResumeRoutes.Characters => await StoryIntelligenceReviewCentre(target.BatchID),
_ => RedirectToAction("Details", "Books", new { id = bookId })
}; };
} }
[HttpPost("story-intelligence/start")] [HttpPost("story-intelligence/start")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> StartStoryIntelligence( public async Task<IActionResult> StartStoryIntelligence()
[FromForm] string? appearancePreference, => RedirectToAction(nameof(StoryIntelligencePlotContext));
[FromForm] string? primaryAgeGroup,
[FromForm] string? storyEra, [HttpGet("story-intelligence/plot-context")]
[FromForm] string? storyLocation) public async Task<IActionResult> StoryIntelligencePlotContext()
{ {
var job = await storyIntelligence.StartAsync(appearancePreference, primaryAgeGroup, storyEra, storyLocation); var overview = await storyIntelligence.GetOverviewAsync();
if (!overview.BookID.HasValue || !overview.CanStart)
{
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
return RedirectToAction(nameof(Index));
}
var model = await wholeBookPlotIntelligence.GetAuthorPlotContextAsync(overview.BookID.Value);
if (model is null)
{
return RedirectToAction(nameof(Index));
}
return View("StoryIntelligenceAuthorPlotContext", model);
}
[HttpGet("story-intelligence/book/{bookId:int}/plot-context")]
public async Task<IActionResult> StoryIntelligenceBookPlotContext(int bookId)
{
var existing = await storyIntelligence.ResumeBookAsync(bookId);
if (existing is not null)
{
TempData["ArchiveMessage"] = "Story Intelligence is already available for this book. Progress is shown in the page header.";
return RedirectToAction("Details", "Books", new { id = bookId });
}
var model = await wholeBookPlotIntelligence.GetAuthorPlotContextAsync(bookId);
if (model is null)
{
TempData["ArchiveError"] = "Story Intelligence could not find that book.";
return RedirectToAction("Details", "Books", new { id = bookId });
}
return View("StoryIntelligenceAuthorPlotContext", model);
}
[HttpPost("story-intelligence/plot-context/start")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartStoryIntelligenceWithPlotContext(StoryIntelligenceAuthorPlotContextForm form)
{
if (form.ProjectID <= 0 || form.BookID <= 0)
{
TempData["OnboardingStoryIntelligenceError"] = "Story Intelligence could not find the selected book.";
return RedirectToAction(nameof(Index));
}
await wholeBookPlotIntelligence.SaveAuthorPlotContextAsync(form.ProjectID, form.BookID, form.PlotSeeds);
OnboardingStoryIntelligenceBatch? job;
try
{
var overview = await storyIntelligence.GetOverviewAsync();
var isCurrentOnboardingBook = overview.BookID == form.BookID && overview.PreviewID != Guid.Empty && overview.CanStart;
job = isCurrentOnboardingBook
? await storyIntelligence.StartAsync()
: await storyIntelligence.StartBookAsync(form.BookID);
}
catch (InvalidOperationException ex)
{
TempData["ArchiveError"] = ex.Message;
return RedirectToAction("Details", "Books", new { id = form.BookID });
}
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(StoryIntelligenceContext)); return RedirectToAction("Details", "Books", new { id = form.BookID });
} }
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID }); var progress = await storyIntelligence.GetProgressAsync(job.BatchID);
if (progress is not null)
{
TempData["ArchiveMessage"] = "Story Intelligence is now analysing your book. Progress is shown in the page header.";
return RedirectToBook(progress);
}
return RedirectToAction("Details", "Books", new { id = job.BookID });
}
[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)
{
TempData["ArchiveMessage"] = "Story Intelligence is already analysing this book. Progress is shown in the page header.";
return RedirectToAction("Details", "Books", new { id = bookId });
}
return RedirectToAction(nameof(StoryIntelligenceBookPlotContext), new { bookId });
} }
[HttpPost("story-intelligence/skip")] [HttpPost("story-intelligence/skip")]
@ -147,7 +259,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
public async Task<IActionResult> StoryIntelligenceProgress(Guid batchId) public async Task<IActionResult> StoryIntelligenceProgress(Guid batchId)
{ {
var model = await storyIntelligence.GetProgressAsync(batchId); var model = await storyIntelligence.GetProgressAsync(batchId);
return model is null ? NotFound() : View(model); if (model is null)
{
return NotFound();
}
return model.SceneAnalysisComplete
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId })
: RedirectToBook(model);
} }
[HttpGet("story-intelligence/review")] [HttpGet("story-intelligence/review")]
@ -161,18 +280,19 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns) if (model.HasActiveRuns)
{ {
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); return RedirectToBook(model);
} }
return model.AllCommitted return model.SceneAnalysisComplete
? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId })
: View(model); : RedirectToBook(model);
} }
[HttpGet("story-intelligence/characters")] [HttpGet("story-intelligence/review-centre")]
public async Task<IActionResult> StoryIntelligenceCharacters(Guid batchId) public async Task<IActionResult> StoryIntelligenceReviewCentre(Guid batchId)
{ {
var model = await storyIntelligence.GetProgressAsync(batchId); var stopwatch = Stopwatch.StartNew();
var model = await storyIntelligence.GetReviewCentreAsync(batchId);
if (model is null) if (model is null)
{ {
return NotFound(); return NotFound();
@ -180,18 +300,54 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns) if (model.HasActiveRuns)
{ {
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); return RedirectToBook(model);
} }
return !model.AllCommitted if (!model.SceneAnalysisComplete)
? RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }) {
return RedirectToBook(model);
}
logger.LogInformation(
"Story Intelligence Review Centre GET rendered. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} ElapsedMs={ElapsedMs} ActiveRuns={ActiveRuns} SceneAnalysisComplete={SceneAnalysisComplete}",
batchId,
model.ProjectID,
model.BookID,
stopwatch.ElapsedMilliseconds,
model.HasActiveRuns,
model.SceneAnalysisComplete);
return View(nameof(StoryIntelligenceReviewCentre), model);
}
[HttpGet("story-intelligence/characters")]
public async Task<IActionResult> StoryIntelligenceCharacters(Guid batchId)
{
var model = await storyIntelligence.GetCharacterReviewAsync(batchId);
if (model is null)
{
return NotFound();
}
if (model.HasActiveRuns)
{
return RedirectToBook(model);
}
if (!model.SceneAnalysisComplete)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
return model.CharacterGateSatisfied && model.CharacterReview.Candidates.Count == 0
? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId })
: View(model); : View(model);
} }
[HttpGet("story-intelligence/locations")] [HttpGet("story-intelligence/locations")]
public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId) public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId)
{ {
var model = await storyIntelligence.GetProgressAsync(batchId); var model = await storyIntelligence.GetLocationReviewAsync(batchId);
if (model is null) if (model is null)
{ {
return NotFound(); return NotFound();
@ -199,23 +355,23 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns) if (model.HasActiveRuns)
{ {
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); return RedirectToBook(model);
} }
if (!model.AllCommitted) if (!model.SceneAnalysisComplete)
{ {
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
} }
return !model.PipelineDashboard.CharacterStageComplete return !model.CharacterGateSatisfied
? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) ? RedirectToReviewCentreWithCharacterGate(batchId)
: View(model); : View(model);
} }
[HttpGet("story-intelligence/assets")] [HttpGet("story-intelligence/assets")]
public async Task<IActionResult> StoryIntelligenceAssets(Guid batchId) public async Task<IActionResult> StoryIntelligenceAssets(Guid batchId)
{ {
var model = await storyIntelligence.GetProgressAsync(batchId); var model = await storyIntelligence.GetAssetReviewAsync(batchId);
if (model is null) if (model is null)
{ {
return NotFound(); return NotFound();
@ -223,28 +379,26 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns) if (model.HasActiveRuns)
{ {
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); return RedirectToBook(model);
} }
if (!model.AllCommitted) if (!model.SceneAnalysisComplete)
{ {
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
} }
if (!model.PipelineDashboard.CharacterStageComplete) if (!model.CharacterGateSatisfied)
{ {
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); return RedirectToReviewCentreWithCharacterGate(batchId);
} }
return !model.PipelineDashboard.LocationStageComplete return View(model);
? RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId })
: View(model);
} }
[HttpGet("story-intelligence/relationships")] [HttpGet("story-intelligence/relationships")]
public async Task<IActionResult> StoryIntelligenceRelationships(Guid batchId) public async Task<IActionResult> StoryIntelligenceRelationships(Guid batchId)
{ {
var model = await storyIntelligence.GetProgressAsync(batchId); var model = await storyIntelligence.GetRelationshipReviewAsync(batchId);
if (model is null) if (model is null)
{ {
return NotFound(); return NotFound();
@ -252,27 +406,20 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns) if (model.HasActiveRuns)
{ {
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); return RedirectToBook(model);
} }
if (!model.AllCommitted) if (!model.SceneAnalysisComplete)
{ {
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
} }
if (!model.PipelineDashboard.CharacterStageComplete) if (!model.CharacterGateSatisfied)
{ {
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); return RedirectToReviewCentreWithCharacterGate(batchId);
} }
if (!model.PipelineDashboard.LocationStageComplete) return View(model);
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
return !model.PipelineDashboard.AssetStageComplete
? RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId })
: View(model);
} }
[HttpPost("story-intelligence/cancel")] [HttpPost("story-intelligence/cancel")]
@ -286,7 +433,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
} }
TempData["OnboardingStoryIntelligenceMessage"] = "Analysis cancellation requested."; TempData["OnboardingStoryIntelligenceMessage"] = "Analysis cancellation requested.";
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); return RedirectToBook(progress);
} }
[HttpPost("story-intelligence/commit")] [HttpPost("story-intelligence/commit")]
@ -300,13 +447,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
} }
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
if (result.Success && progress.AllCommitted) return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
{
TempData["WriterWorkspaceMessage"] = $"{progress.CommittedChapterCount} chapter(s) and {progress.Chapters.Sum(chapter => chapter.ScenesCreated)} scene(s) created from your manuscript.";
return RedirectToAction("Index", "Writer", new { projectId = progress.ProjectID });
}
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
} }
[HttpPost("story-intelligence/commit-all")] [HttpPost("story-intelligence/commit-all")]
@ -320,9 +461,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
} }
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success && progress.AllCommitted return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId })
: RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
} }
[HttpPost("story-intelligence/characters")] [HttpPost("story-intelligence/characters")]
@ -337,15 +476,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success return result.Success
? RedirectToAction(nameof(StoryIntelligenceCharacterComplete), new { batchId = form.BatchID }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = form.BatchID });
} }
[HttpGet("story-intelligence/characters/complete")] [HttpGet("story-intelligence/characters/complete")]
public async Task<IActionResult> StoryIntelligenceCharacterComplete(Guid batchId) public IActionResult StoryIntelligenceCharacterComplete(Guid batchId)
{ {
var model = await storyIntelligence.GetCharacterImportResultAsync(batchId); return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
return model is null ? NotFound() : View(model);
} }
[HttpPost("story-intelligence/locations")] [HttpPost("story-intelligence/locations")]
@ -360,15 +498,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success return result.Success
? RedirectToAction(nameof(StoryIntelligenceLocationComplete), new { batchId = form.BatchID }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID });
} }
[HttpGet("story-intelligence/locations/complete")] [HttpGet("story-intelligence/locations/complete")]
public async Task<IActionResult> StoryIntelligenceLocationComplete(Guid batchId) public IActionResult StoryIntelligenceLocationComplete(Guid batchId)
{ {
var model = await storyIntelligence.GetLocationImportResultAsync(batchId); return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
return model is null ? NotFound() : View(model);
} }
[HttpPost("story-intelligence/assets")] [HttpPost("story-intelligence/assets")]
@ -383,15 +520,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success return result.Success
? RedirectToAction(nameof(StoryIntelligenceAssetComplete), new { batchId = form.BatchID }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID });
} }
[HttpGet("story-intelligence/assets/complete")] [HttpGet("story-intelligence/assets/complete")]
public async Task<IActionResult> StoryIntelligenceAssetComplete(Guid batchId) public IActionResult StoryIntelligenceAssetComplete(Guid batchId)
{ {
var model = await storyIntelligence.GetAssetImportResultAsync(batchId); return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
return model is null ? NotFound() : View(model);
} }
[HttpPost("story-intelligence/relationships")] [HttpPost("story-intelligence/relationships")]
@ -406,21 +542,20 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success return result.Success
? RedirectToAction(nameof(StoryIntelligenceRelationshipComplete), new { batchId = form.BatchID }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID });
} }
[HttpGet("story-intelligence/relationships/complete")] [HttpGet("story-intelligence/relationships/complete")]
public async Task<IActionResult> StoryIntelligenceRelationshipComplete(Guid batchId) public IActionResult StoryIntelligenceRelationshipComplete(Guid batchId)
{ {
var model = await storyIntelligence.GetRelationshipImportResultAsync(batchId); return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
return model is null ? NotFound() : View(model);
} }
[HttpGet("story-intelligence/knowledge")] [HttpGet("story-intelligence/knowledge")]
public async Task<IActionResult> StoryIntelligenceKnowledge(Guid batchId) public async Task<IActionResult> StoryIntelligenceKnowledge(Guid batchId)
{ {
var model = await storyIntelligence.GetProgressAsync(batchId); var model = await storyIntelligence.GetKnowledgeReviewAsync(batchId);
if (model is null) if (model is null)
{ {
return NotFound(); return NotFound();
@ -428,32 +563,20 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
if (model.HasActiveRuns) if (model.HasActiveRuns)
{ {
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); return RedirectToBook(model);
} }
if (!model.AllCommitted) if (!model.SceneAnalysisComplete)
{ {
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
} }
if (!model.PipelineDashboard.CharacterStageComplete) if (!model.CharacterGateSatisfied)
{ {
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); return RedirectToReviewCentreWithCharacterGate(batchId);
} }
if (!model.PipelineDashboard.LocationStageComplete) return View(model);
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
if (!model.PipelineDashboard.AssetStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
}
return !model.PipelineDashboard.RelationshipStageComplete
? RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId })
: View(model);
} }
[HttpPost("story-intelligence/knowledge")] [HttpPost("story-intelligence/knowledge")]
@ -468,15 +591,82 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success return result.Success
? RedirectToAction(nameof(StoryIntelligenceKnowledgeComplete), new { batchId = form.BatchID }) ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID }); : RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID });
} }
[HttpGet("story-intelligence/knowledge/complete")] [HttpGet("story-intelligence/knowledge/complete")]
public async Task<IActionResult> StoryIntelligenceKnowledgeComplete(Guid batchId) public IActionResult StoryIntelligenceKnowledgeComplete(Guid batchId)
{ {
var model = await storyIntelligence.GetKnowledgeImportResultAsync(batchId); return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
return model is null ? NotFound() : View(model); }
[HttpGet("story-intelligence/narrative")]
public async Task<IActionResult> StoryIntelligenceNarrative(Guid batchId)
{
var model = await storyIntelligence.GetNarrativeReviewAsync(batchId);
if (model is null)
{
return NotFound();
}
if (model.HasActiveRuns)
{
return RedirectToBook(model);
}
if (!model.SceneAnalysisComplete)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
if (!model.CharacterGateSatisfied)
{
return RedirectToReviewCentreWithCharacterGate(batchId);
}
return 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(StoryIntelligenceReviewCentre), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = form.BatchID });
}
[HttpPost("story-intelligence/plot-synthesis/retry")]
public async Task<IActionResult> RetryStoryIntelligencePlotSynthesis(Guid batchId)
{
var model = await storyIntelligence.GetProgressAsync(batchId);
if (model is null)
{
return NotFound();
}
if (!currentUser.UserId.HasValue)
{
return Forbid();
}
await wholeBookPlotIntelligence.RetryAsync(model.BookID, currentUser.UserId.Value);
TempData["OnboardingStoryIntelligenceMessage"] = "Plot Lines analysis has been queued again.";
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpGet("story-intelligence/narrative/complete")]
public IActionResult StoryIntelligenceNarrativeComplete(Guid batchId)
{
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
} }
[HttpGet("story-intelligence/complete")] [HttpGet("story-intelligence/complete")]
@ -488,35 +678,27 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return NotFound(); return NotFound();
} }
if (!progress.PipelineDashboard.CharacterStageComplete) if (!progress.CharacterGateSatisfied)
{ {
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
if (!progress.PipelineDashboard.LocationStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
if (!progress.PipelineDashboard.AssetStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
}
if (!progress.PipelineDashboard.RelationshipStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId });
}
if (!progress.PipelineDashboard.KnowledgeStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceKnowledge), 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);
} }
private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model)
=> RedirectToAction("Details", "Books", new { id = model.BookID });
private IActionResult RedirectToBook(StoryIntelligenceOverviewViewModel model)
=> RedirectToAction("Details", "Books", new { id = model.BookID });
private IActionResult RedirectToReviewCentreWithCharacterGate(Guid batchId)
{
TempData["OnboardingStoryIntelligenceMessage"] = "Resolve Characters before reviewing this category.";
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId });
}
[HttpPost("scan-review")] [HttpPost("scan-review")]
[RequestSizeLimit(25 * 1024 * 1024)] [RequestSizeLimit(25 * 1024 * 1024)]
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)] [RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]
@ -526,23 +708,47 @@ 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, "continue", StringComparison.OrdinalIgnoreCase); var readyToImport = string.Equals(intent, "structure", 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
? "Review saved. Next: story and portrait context." ? "Chapter review saved. Next: import scenes and update Word."
: 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(StoryIntelligence)) ? RedirectToAction(nameof(Index))
: 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 });
@ -556,7 +762,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return NotFound(); return NotFound();
} }
if (readyToImport || continueCharacterReview || backCharacterReview) if (continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport)
{ {
ViewData["ScanReviewStep"] = "characters"; ViewData["ScanReviewStep"] = "characters";
ViewData["CharacterReviewPage"] = readyToImport ViewData["CharacterReviewPage"] = readyToImport

View File

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

View File

@ -6,7 +6,7 @@ using PlotLine.ViewModels;
namespace PlotLine.Controllers; namespace PlotLine.Controllers;
[Authorize] [Authorize]
public sealed class ProjectsController(IProjectService projects, IProjectActivityService activity) : Controller public sealed class ProjectsController(IProjectService projects, IProjectActivityService activity, ILogger<ProjectsController> logger) : Controller
{ {
public async Task<IActionResult> Index() => View(await projects.ListAsync()); public async Task<IActionResult> Index() => View(await projects.ListAsync());
@ -92,15 +92,19 @@ 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));
} }
@ -108,10 +112,12 @@ 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;

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using PlotLine.Models;
using PlotLine.Services; using PlotLine.Services;
namespace PlotLine.Controllers; namespace PlotLine.Controllers;
@ -9,7 +10,10 @@ public sealed class RelationshipMapController(IRelationshipMapService relationsh
{ {
public async Task<IActionResult> Index(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork = false) public async Task<IActionResult> Index(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork = false)
{ {
var model = await relationshipMap.GetAsync(projectId, bookId, chapterId, sceneId, focusCharacterId, showFullNetwork); var position = StoryPosition.FromTimelineSelection(projectId, bookId, chapterId, sceneId);
var model = position is null
? null
: await relationshipMap.GetAsync(position, focusCharacterId, showFullNetwork);
return model is null ? NotFound() : View(model); return model is null ? NotFound() : View(model);
} }
} }

View File

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

View File

@ -13,7 +13,9 @@ namespace PlotLine.Controllers;
[Route("api/word-companion")] [Route("api/word-companion")]
public sealed class WordCompanionController( public sealed class WordCompanionController(
IWordCompanionService wordCompanion, IWordCompanionService wordCompanion,
IHubContext<WordCompanionFollowHub> followHub) : ControllerBase IWordCompanionPresenceService presence,
IHubContext<WordCompanionFollowHub> followHub,
ILogger<WordCompanionController> logger) : ControllerBase
{ {
[HttpGet("projects")] [HttpGet("projects")]
public Task<WordCompanionProjectsResponse> GetProjects() => wordCompanion.ListProjectsAsync(); public Task<WordCompanionProjectsResponse> GetProjects() => wordCompanion.ListProjectsAsync();
@ -124,19 +126,54 @@ 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,
response.BookId, response.BookId,
response.ChapterId, response.ChapterId,
response.SceneId, response.SceneId,
response.SceneUrl response.SceneUrl,
response.Position
});
await BroadcastToCurrentUserAsync("storyPositionChanged", new
{
Source = "WordCompanion",
response.ProjectId,
response.BookId,
response.ChapterId,
response.SceneId,
CanonicalUrl = response.SceneUrl,
response.Position
}); });
return Ok(response); return Ok(response);
@ -255,4 +292,12 @@ 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.");
}
} }

View File

@ -7,13 +7,21 @@ namespace PlotLine.Controllers;
[AllowAnonymous] [AllowAnonymous]
[Route("word-companion")] [Route("word-companion")]
public sealed class WordCompanionHostController(ICurrentUserService currentUser, IAuthorizationService authorization) : Controller public sealed class WordCompanionHostController(
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
{ {
@ -25,4 +33,3 @@ public sealed class WordCompanionHostController(ICurrentUserService currentUser,
}); });
} }
} }

View File

@ -0,0 +1,256 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize]
[Route("Writing")]
public sealed class WritingController(
IStoryContextService storyContext,
IRelationshipMapService relationshipMap,
IFloorPlanService floorPlans,
IWriterWorkspaceService writerWorkspace,
ILogger<WritingController> logger) : Controller
{
[HttpGet("Context")]
public async Task<IActionResult> Context(int? projectId, int? bookId, int? chapterId, int? sceneId, Guid? documentGuid)
{
logger.LogInformation(
"Opening Writing Context. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} DocumentGuidPresent={DocumentGuidPresent}",
projectId,
bookId,
chapterId,
sceneId,
documentGuid.HasValue);
var display = sceneId.HasValue
? await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual context")
: WaitingContextDisplay(projectId, bookId, documentGuid);
var model = new WritingContextPageViewModel
{
ProjectID = projectId,
BookID = bookId,
ChapterID = chapterId,
SceneID = sceneId,
DocumentGuid = documentGuid,
Display = display ?? MissingContextDisplay(projectId, bookId, sceneId)
};
return View(model);
}
[HttpGet("Intent")]
public async Task<IActionResult> Intent(int? projectId, int? bookId, int? chapterId, int? sceneId, Guid? documentGuid)
{
logger.LogInformation(
"Opening Writing Intent. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} DocumentGuidPresent={DocumentGuidPresent}",
projectId,
bookId,
chapterId,
sceneId,
documentGuid.HasValue);
var display = sceneId.HasValue || chapterId.HasValue
? await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual intent")
: WaitingIntentDisplay(projectId, bookId, documentGuid);
var model = new WritingIntentPageViewModel
{
ProjectID = projectId,
BookID = bookId,
ChapterID = chapterId,
SceneID = sceneId,
DocumentGuid = documentGuid,
Display = display ?? MissingIntentDisplay(projectId, bookId, chapterId, sceneId)
};
return View(model);
}
[HttpGet("Context/Scene")]
public async Task<IActionResult> ContextScene(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
if (!sceneId.HasValue)
{
logger.LogWarning("Writing Context partial requested without SceneID. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID}", projectId, bookId, chapterId);
return PartialView("~/Views/Writing/_WritingContextContent.cshtml", WaitingContextDisplay(projectId, bookId, null));
}
var model = await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
if (model?.HasScene == true)
{
return PartialView("~/Views/Writing/_WritingContextContent.cshtml", model);
}
logger.LogWarning("Writing Context could not resolve scene. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID}", projectId, bookId, chapterId, sceneId);
return PartialView("~/Views/Writing/_WritingContextContent.cshtml", MissingContextDisplay(projectId, bookId, sceneId));
}
[HttpGet("Intent/Scene")]
public async Task<IActionResult> IntentScene(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
if (!sceneId.HasValue && !chapterId.HasValue)
{
logger.LogWarning("Writing Intent partial requested without ChapterID or SceneID. ProjectID={ProjectID} BookID={BookID}", projectId, bookId);
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", WaitingIntentDisplay(projectId, bookId, null));
}
var model = await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
if (model is not null && (model.HasScene || model.HasChapter))
{
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", model);
}
logger.LogWarning("Writing Intent could not resolve chapter/scene. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID}", projectId, bookId, chapterId, sceneId);
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", MissingIntentDisplay(projectId, bookId, chapterId, sceneId));
}
private async Task<WritingContextDisplayViewModel?> BuildDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
{
if (!sceneId.HasValue)
{
return new WritingContextDisplayViewModel { FollowStatus = followStatus };
}
var context = await storyContext.GetForSceneAsync(sceneId.Value, projectId, bookId);
if (context?.Scene is null)
{
return null;
}
var position = context.Position;
var relationships = await relationshipMap.GetAsync(position, null, true);
var floorPlan = context.Scene.FloorPlanID.HasValue
? await floorPlans.GetEditorAsync(context.Scene.FloorPlanID.Value)
: null;
return new WritingContextDisplayViewModel
{
Context = context,
RelationshipMap = relationships,
FloorPlan = floorPlan,
FollowStatus = followStatus,
SceneOptions = BuildSceneOptions(context.Scene)
};
}
private async Task<WritingIntentDisplayViewModel?> BuildIntentDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
{
StoryContextViewModel? context = null;
if (sceneId.HasValue)
{
context = await storyContext.GetForSceneAsync(sceneId.Value, projectId, bookId);
if (context?.Scene is null)
{
return null;
}
}
var resolvedChapterId = context?.Position.ChapterId ?? chapterId;
var chapterWorkflow = resolvedChapterId.HasValue
? await writerWorkspace.GetChapterWorkflowAsync(resolvedChapterId.Value)
: null;
if (chapterWorkflow is not null)
{
if (projectId.HasValue && chapterWorkflow.Project.ProjectID != projectId.Value)
{
return null;
}
if (bookId.HasValue && chapterWorkflow.Book.BookID != bookId.Value)
{
return null;
}
}
if (context is null && chapterWorkflow is null)
{
return null;
}
return new WritingIntentDisplayViewModel
{
Context = context,
ChapterWorkflow = chapterWorkflow,
FollowStatus = followStatus,
SceneOptions = context?.Scene is not null
? BuildSceneOptions(context.Scene)
: BuildSceneOptions(chapterWorkflow)
};
}
private static IReadOnlyList<SelectListItem> BuildSceneOptions(SceneEditViewModel scene)
{
var options = scene.SceneOptions
.Select(x => new SelectListItem(x.Text, x.Value, x.Value == scene.SceneID.ToString()))
.ToList();
if (!options.Any(x => x.Value == scene.SceneID.ToString()))
{
options.Insert(0, new SelectListItem(
$"{scene.Book?.BookDisplayTitle ?? "Book"} / Ch {scene.Chapter?.ChapterNumber:g} / Scene {scene.SceneNumber:g}: {scene.SceneTitle}",
scene.SceneID.ToString(),
true));
}
return options
.OrderByDescending(x => x.Selected)
.ThenBy(x => x.Text)
.ToList();
}
private static IReadOnlyList<SelectListItem> BuildSceneOptions(ChapterWorkflowViewModel? chapterWorkflow)
{
return chapterWorkflow?.Scenes
.Select(x => new SelectListItem(
$"Scene {x.SceneNumber:g}: {x.SceneTitle}",
x.SceneID.ToString()))
.OrderBy(x => x.Text)
.ToList()
?? [];
}
private static WritingContextDisplayViewModel WaitingContextDisplay(int? projectId, int? bookId, Guid? documentGuid) => new()
{
FollowStatus = "No current Word scene received",
EmptyTitle = "No current Word scene received",
EmptyMessage = MissingScopeMessage(projectId, bookId, documentGuid, "Context")
};
private static WritingIntentDisplayViewModel WaitingIntentDisplay(int? projectId, int? bookId, Guid? documentGuid) => new()
{
FollowStatus = "No current Word scene received",
EmptyTitle = "No current Word scene received",
EmptyMessage = MissingScopeMessage(projectId, bookId, documentGuid, "Intent")
};
private static WritingContextDisplayViewModel MissingContextDisplay(int? projectId, int? bookId, int? sceneId) => new()
{
FollowStatus = "Context unavailable",
EmptyTitle = "No Story Intelligence context is available",
EmptyMessage = $"PlotDirector could not load context for scene {sceneId?.ToString() ?? "unknown"} in project {projectId?.ToString() ?? "unknown"}, book {bookId?.ToString() ?? "unknown"}."
};
private static WritingIntentDisplayViewModel MissingIntentDisplay(int? projectId, int? bookId, int? chapterId, int? sceneId) => new()
{
FollowStatus = "Intent unavailable",
EmptyTitle = "No writing intent is available",
EmptyMessage = $"PlotDirector could not load writing intent for chapter {chapterId?.ToString() ?? "unknown"}, scene {sceneId?.ToString() ?? "none"} in project {projectId?.ToString() ?? "unknown"}, book {bookId?.ToString() ?? "unknown"}."
};
private static string MissingScopeMessage(int? projectId, int? bookId, Guid? documentGuid, string screen)
{
if (!projectId.HasValue || !bookId.HasValue)
{
return $"{screen} did not receive a project/book from Word Companion. Reopen it from a linked manuscript, or choose a scene manually.";
}
return $"Waiting for Word Companion to report the current scene for project {projectId}, book {bookId}{(documentGuid.HasValue ? " and this Word document" : "")}.";
}
}

View File

@ -107,6 +107,9 @@ 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

View File

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

View File

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

View File

@ -8,6 +8,16 @@ 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
@ -31,6 +41,22 @@ 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)
{ {
@ -61,6 +87,115 @@ 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
@ -87,6 +222,7 @@ 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; }
} }
@ -97,3 +233,54 @@ 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; }
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -75,6 +75,8 @@ 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);
} }
@ -84,7 +86,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); Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false);
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);
@ -141,6 +143,7 @@ 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);
} }
@ -165,6 +168,14 @@ 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, int? mergeTargetCandidateId = null);
}
public interface IAssetRepository public interface IAssetRepository
{ {
Task<AssetLookupData> GetLookupsAsync(int? projectId = null); Task<AssetLookupData> GetLookupsAsync(int? projectId = null);
@ -207,6 +218,7 @@ 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);
@ -2097,10 +2109,31 @@ 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)
@ -2116,10 +2149,43 @@ 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.
}
} }
} }
@ -2289,6 +2355,8 @@ 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,
@ -2397,7 +2465,33 @@ 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 }, new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID, chapter.POVCharacterID },
commandType: CommandType.StoredProcedure);
}
public async Task UpdatePovCharacterAsync(int chapterId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.Chapter_UpdatePovCharacter",
new
{
ChapterID = chapterId,
POVCharacterID = povCharacterId,
POVSource = source,
POVConfidence = confidence,
POVEvidence = evidence,
POVSuggestedName = suggestedName
},
commandType: CommandType.StoredProcedure);
}
public async Task<int> SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.Chapter_SetScenesPov",
new { ChapterID = chapterId, POVCharacterID = povCharacterId, OverwriteAuthor = overwriteAuthor },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
@ -2470,17 +2564,27 @@ 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) public async Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync( await connection.ExecuteAsync(
"dbo.Scene_UpdatePovCharacter", "dbo.Scene_UpdatePovCharacter",
new { SceneID = sceneId, POVCharacterID = povCharacterId }, new
{
SceneID = sceneId,
POVCharacterID = povCharacterId,
POVSource = source,
POVConfidence = confidence,
POVEvidence = evidence,
POVSuggestedName = suggestedName,
OverwriteAuthor = overwriteAuthor
},
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
@ -2864,6 +2968,15 @@ 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();
@ -3126,6 +3239,105 @@ 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.ChangeType,
candidate.Significance,
candidate.ExistingPlotLineID,
candidate.ExistingPlotThreadID,
candidate.WholeBookPlotSynthesisRunID,
candidate.AuthorPlotSeedID,
candidate.ExistingPlotLineMatchConfidence,
candidate.ExistingPlotLineMatchReason,
candidate.SeedMatchConfidence,
candidate.SeedMatchReason,
candidate.Source,
candidate.RelatedPlotLineID,
candidate.ParentCandidateKey,
candidate.ParentNarrativeCandidateID,
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 events = result.IsConsumed
? []
: (await result.ReadAsync<StoryIntelligenceNarrativeCandidateEvent>()).ToList();
var evidenceByCandidate = evidence
.GroupBy(item => item.NarrativeCandidateID)
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoryIntelligenceNarrativeCandidateEvidence>)group.ToList());
var eventsByCandidate = events
.GroupBy(item => item.NarrativeCandidateID)
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoryIntelligenceNarrativeCandidateEvent>)group.ToList());
foreach (var candidate in candidates)
{
candidate.Evidence = evidenceByCandidate.TryGetValue(candidate.NarrativeCandidateID, out var items) ? items : [];
candidate.Events = eventsByCandidate.TryGetValue(candidate.NarrativeCandidateID, out var candidateEvents) ? candidateEvents : [];
}
return candidates;
}
public async Task MarkReviewedAsync(int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId, int? mergeTargetCandidateId = null)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceNarrativeCandidate_MarkReviewed",
new
{
NarrativeCandidateID = candidateId,
Status = status,
ReviewAction = reviewAction,
ReviewedByUserID = userId,
AcceptedPlotLineID = acceptedPlotLineId,
AcceptedPlotThreadID = acceptedPlotThreadId,
MergeTargetNarrativeCandidateID = mergeTargetCandidateId
},
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)
@ -3542,6 +3754,22 @@ 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();
@ -3671,7 +3899,12 @@ 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);
} }

View File

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

View File

@ -9,9 +9,13 @@ public interface IStoryIntelligencePipelineRepository
Task<StoryIntelligenceBookPipelineState?> GetByIdForUserAsync(int importSessionId, int userId); Task<StoryIntelligenceBookPipelineState?> GetByIdForUserAsync(int importSessionId, int userId);
Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId); Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId);
Task<StoryIntelligenceBookPipelineState?> GetByBookForUserAsync(int bookId, int userId); Task<StoryIntelligenceBookPipelineState?> GetByBookForUserAsync(int bookId, int userId);
Task<StoryIntelligenceReviewCentreSummary?> GetReviewCentreSummaryAsync(int bookId, int userId);
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<StoryIntelligenceSceneAnalysisRecoveryCandidate?> FindNextReadySceneAnalysisAsync();
Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteSceneAnalysisAsync(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
@ -43,6 +47,15 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<StoryIntelligenceReviewCentreSummary?> GetReviewCentreSummaryAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceReviewCentreSummary>(
"dbo.StoryIntelligenceReviewCentre_GetSummary",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId) public async Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -67,7 +80,13 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
request.CurrentReviewStage, request.CurrentReviewStage,
request.Status, request.Status,
request.CompletedUtc, request.CompletedUtc,
request.LastRunID request.LastRunID,
request.MarkCharacterReviewComplete,
request.MarkLocationReviewComplete,
request.MarkAssetReviewComplete,
request.MarkRelationshipReviewComplete,
request.MarkKnowledgeReviewComplete,
request.MarkNarrativeReviewComplete
}, },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
@ -81,4 +100,30 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
return rows.ToList(); return rows.ToList();
} }
public async Task<StoryIntelligenceSceneAnalysisRecoveryCandidate?> FindNextReadySceneAnalysisAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceSceneAnalysisRecoveryCandidate>(
"dbo.StoryIntelligenceBookPipeline_FindNextReadySceneAnalysis",
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteSceneAnalysisAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceSceneAnalysisCompletion>(
"dbo.StoryIntelligenceBookPipeline_TryCompleteSceneAnalysis",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceImportNotificationTarget>(
"dbo.StoryIntelligenceBookPipeline_TryBeginCompletionNotification",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
} }

View File

@ -9,6 +9,7 @@ public interface IStoryIntelligenceResultRepository
Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request); Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request);
Task<int> QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request); Task<int> QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request);
Task<StoryIntelligenceQueuedRun?> ClaimNextPendingAsync(); Task<StoryIntelligenceQueuedRun?> ClaimNextPendingAsync();
Task<StoryIntelligenceQueuedRun?> ClaimNextPendingFairAsync(int maxConcurrentRunsPerBook, int maxConcurrentRunsPerUser, int claimLeaseMinutes);
Task UpdateProgressAsync( Task UpdateProgressAsync(
int runId, int runId,
string? status = null, string? status = null,
@ -31,9 +32,11 @@ public interface IStoryIntelligenceResultRepository
Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene); Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene);
Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync(); Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceActiveBookRunSummary>> ListActiveBookRunSummariesForUserAsync(int userId);
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);
@ -320,6 +323,23 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<StoryIntelligenceQueuedRun?> ClaimNextPendingFairAsync(
int maxConcurrentRunsPerBook,
int maxConcurrentRunsPerUser,
int claimLeaseMinutes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceQueuedRun>(
"dbo.StoryIntelligenceRun_ClaimNextPendingFair",
new
{
MaxConcurrentRunsPerBook = Math.Max(1, maxConcurrentRunsPerBook),
MaxConcurrentRunsPerUser = Math.Max(1, maxConcurrentRunsPerUser),
ClaimLeaseMinutes = Math.Max(15, claimLeaseMinutes)
},
commandType: CommandType.StoredProcedure);
}
public async Task UpdateProgressAsync( public async Task UpdateProgressAsync(
int runId, int runId,
string? status = null, string? status = null,
@ -489,6 +509,16 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
return rows.ToList(); return rows.ToList();
} }
public async Task<IReadOnlyList<StoryIntelligenceActiveBookRunSummary>> ListActiveBookRunSummariesForUserAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceActiveBookRunSummary>(
"dbo.StoryIntelligenceRun_ListActiveBookSummaryForUser",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId) public async Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -517,6 +547,20 @@ 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();
@ -566,15 +610,31 @@ 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 = readiness.BlockReason ?? "This chapter already contains scenes." Message = $"This chapter contains {existingScenes.Count:N0} existing scene(s), but Story Intelligence produced {orderedScenes.Count:N0} scene result(s)."
}; };
} }
}
if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue) if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue)
{ {
@ -590,7 +650,6 @@ 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];
@ -612,7 +671,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
"dbo.Scene_Save", "dbo.Scene_Save",
new new
{ {
SceneID = (int?)null, SceneID = item.SceneID ?? (existingScenes.Count > index ? existingScenes[index].SceneID : (int?)null),
ChapterID = request.ChapterID, ChapterID = request.ChapterID,
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber), SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
SceneTitle = sceneTitle, SceneTitle = sceneTitle,
@ -737,7 +796,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
} }
var notes = new List<string>(); var notes = new List<string>();
notes.Add("Committed into empty chapter."); notes.Add(existingScenes.Count > 0 ? "Enriched existing structural scenes." : "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)));
@ -764,7 +823,9 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
CommitID = commitId, CommitID = commitId,
ScenesCreated = scenesCreated, ScenesCreated = scenesCreated,
MetricsCreated = metricsCreated, MetricsCreated = metricsCreated,
Message = $"Created {scenesCreated:N0} scene(s)." Message = existingScenes.Count > 0
? $"Enriched {scenesCreated:N0} existing scene(s)."
: $"Created {scenesCreated:N0} scene(s)."
}; };
} }
catch (Exception ex) catch (Exception ex)

View File

@ -23,6 +23,7 @@ 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);
@ -363,6 +364,18 @@ 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;

View File

@ -0,0 +1,365 @@
using System.Data;
using System.Text.Json;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IWholeBookPlotIntelligenceRepository
{
Task SaveAuthorSeedsAsync(AuthorPlotSeedSaveRequest request);
Task<IReadOnlyList<AuthorPlotSeed>> ListAuthorSeedsAsync(int bookId);
Task<WholeBookPlotSynthesisRun> QueueAsync(WholeBookPlotSynthesisQueueRequest request);
Task<WholeBookPlotSynthesisRun?> RetryAsync(int bookId, int userId);
Task<WholeBookPlotSynthesisRun?> ClaimNextAsync(int leaseMinutes);
Task<WholeBookPlotSynthesisRun?> GetCurrentAsync(int bookId);
Task CompleteAsync(WholeBookPlotSynthesisCompletionRequest request, WholeBookPlotSynthesisResult result);
Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs);
Task<WholeBookPlotSynthesisContext> BuildContextAsync(int projectId, int bookId);
}
public sealed class WholeBookPlotIntelligenceRepository(ISqlConnectionFactory connectionFactory) : IWholeBookPlotIntelligenceRepository
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task SaveAuthorSeedsAsync(AuthorPlotSeedSaveRequest request)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.AuthorPlotSeed_SaveForBook",
new
{
request.ProjectID,
request.BookID,
SeedsJson = JsonSerializer.Serialize(
request.Descriptions
.Where(description => !string.IsNullOrWhiteSpace(description))
.Select(description => new { description = description.Trim() })
.ToList(),
JsonOptions)
},
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<AuthorPlotSeed>> ListAuthorSeedsAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<AuthorPlotSeed>(
"dbo.AuthorPlotSeed_ListByBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<WholeBookPlotSynthesisRun> QueueAsync(WholeBookPlotSynthesisQueueRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<WholeBookPlotSynthesisRun>(
"dbo.WholeBookPlotSynthesisRun_Queue",
new
{
request.ProjectID,
request.BookID,
request.UserID,
request.SourceStoryIntelligenceRunID,
request.CharacterResolutionVersion
},
commandType: CommandType.StoredProcedure);
}
public async Task<WholeBookPlotSynthesisRun?> RetryAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WholeBookPlotSynthesisRun>(
"dbo.WholeBookPlotSynthesisRun_Retry",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<WholeBookPlotSynthesisRun?> ClaimNextAsync(int leaseMinutes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WholeBookPlotSynthesisRun>(
"dbo.WholeBookPlotSynthesisRun_ClaimNext",
new { LeaseMinutes = leaseMinutes },
commandType: CommandType.StoredProcedure);
}
public async Task<WholeBookPlotSynthesisRun?> GetCurrentAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WholeBookPlotSynthesisRun>(
"dbo.WholeBookPlotSynthesisRun_GetCurrentByBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
}
public async Task CompleteAsync(WholeBookPlotSynthesisCompletionRequest request, WholeBookPlotSynthesisResult result)
{
using var connection = connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
try
{
await connection.ExecuteAsync(
"dbo.WholeBookPlotSynthesisRun_Complete",
new
{
request.WholeBookPlotSynthesisRunID,
request.Model,
request.OpenAIResponseID,
request.InputTokens,
request.OutputTokens,
request.CachedInputTokens,
request.TotalTokens,
request.DurationMs,
request.ResultJson
},
transaction,
commandType: CommandType.StoredProcedure);
foreach (var plotLine in result.PlotLines)
{
var candidateId = await connection.QuerySingleAsync<int>(
"dbo.WholeBookPlotSynthesisCandidate_Save",
new
{
request.WholeBookPlotSynthesisRunID,
CandidateType = StoryIntelligenceNarrativeCandidateTypes.PlotLine,
CandidateKey = CandidateKey(StoryIntelligenceNarrativeCandidateTypes.PlotLine, plotLine.Key, plotLine.ProposedTitle),
plotLine.ProposedTitle,
ProposedDescription = plotLine.Description,
Category = "Plot Line",
InferredState = plotLine.BookStatus,
ChangeType = plotLine.BookStatus,
plotLine.Significance,
plotLine.ExistingPlotLineID,
ExistingPlotThreadID = (int?)null,
RelatedPlotLineID = (int?)null,
ParentCandidateKey = (string?)null,
ParentNarrativeCandidateID = (int?)null,
plotLine.AuthorPlotSeedID,
plotLine.ExistingPlotLineMatchConfidence,
plotLine.ExistingPlotLineMatchReason,
plotLine.SeedMatchConfidence,
plotLine.SeedMatchReason,
plotLine.Confidence
},
transaction,
commandType: CommandType.StoredProcedure);
var sortOrder = 0;
foreach (var thread in plotLine.Threads)
{
var threadCandidateId = await connection.QuerySingleAsync<int>(
"dbo.WholeBookPlotSynthesisCandidate_Save",
new
{
request.WholeBookPlotSynthesisRunID,
CandidateType = StoryIntelligenceNarrativeCandidateTypes.Thread,
CandidateKey = CandidateKey(StoryIntelligenceNarrativeCandidateTypes.Thread, thread.Key, thread.ProposedTitle),
thread.ProposedTitle,
ProposedDescription = thread.Description,
Category = "Thread",
InferredState = thread.Status,
ChangeType = thread.Status,
Significance = plotLine.Significance,
ExistingPlotLineID = plotLine.ExistingPlotLineID,
thread.ExistingPlotThreadID,
RelatedPlotLineID = plotLine.ExistingPlotLineID,
ParentCandidateKey = CandidateKey(StoryIntelligenceNarrativeCandidateTypes.PlotLine, plotLine.Key, plotLine.ProposedTitle),
ParentNarrativeCandidateID = candidateId,
plotLine.AuthorPlotSeedID,
ExistingPlotLineMatchConfidence = thread.ExistingThreadMatchConfidence,
ExistingPlotLineMatchReason = thread.MatchReason,
SeedMatchConfidence = plotLine.SeedMatchConfidence,
SeedMatchReason = plotLine.SeedMatchReason,
thread.Confidence
},
transaction,
commandType: CommandType.StoredProcedure);
foreach (var item in thread.Events.Where(item => item.SceneID > 0))
{
await connection.ExecuteAsync(
"dbo.WholeBookPlotSynthesisCandidateEvent_Save",
new
{
NarrativeCandidateID = threadCandidateId,
SynthesisRunID = request.WholeBookPlotSynthesisRunID,
EventKey = string.IsNullOrWhiteSpace(item.Key)
? $"{thread.Key}:{item.SceneID}:{item.EventType}:{sortOrder}"
: item.Key,
item.SceneID,
item.EventType,
EventTitle = item.Title,
EventDescription = item.Description,
item.Evidence,
item.Confidence,
SortOrder = sortOrder++
},
transaction,
commandType: CommandType.StoredProcedure);
await connection.ExecuteAsync(
"dbo.StoryIntelligenceNarrativeCandidateEvidence_Save",
new
{
NarrativeCandidateID = threadCandidateId,
item.SceneID,
ContributionType = item.EventType,
Evidence = item.Evidence,
SortOrder = sortOrder
},
transaction,
commandType: CommandType.StoredProcedure);
}
}
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
public async Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.WholeBookPlotSynthesisRun_Fail",
new
{
WholeBookPlotSynthesisRunID = runId,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
DurationMs = durationMs
},
commandType: CommandType.StoredProcedure);
}
public async Task<WholeBookPlotSynthesisContext> BuildContextAsync(int projectId, int bookId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync(
"dbo.WholeBookPlotSynthesis_ContextGet",
new { ProjectID = projectId, BookID = bookId },
commandType: CommandType.StoredProcedure);
var book = await result.ReadSingleAsync<ContextBookRow>();
var sourceRows = (await result.ReadAsync<ContextSceneRow>()).ToList();
var characters = (await result.ReadAsync<WholeBookPlotSynthesisCharacter>()).ToList();
var aliases = (await result.ReadAsync<ContextCharacterAliasRow>()).ToList();
var plotLines = (await result.ReadAsync<WholeBookPlotSynthesisExistingPlotLine>()).ToList();
var threads = (await result.ReadAsync<WholeBookPlotSynthesisExistingThreadRow>()).ToList();
var seeds = (await result.ReadAsync<AuthorPlotSeed>()).ToList();
var audit = await result.ReadSingleAsync<WholeBookPlotSynthesisSourceAudit>();
var aliasesByCharacter = aliases
.GroupBy(alias => alias.CharacterID)
.ToDictionary(group => group.Key, group => (IReadOnlyList<string>)group.Select(alias => alias.Alias).ToList());
characters = characters
.Select(character => new WholeBookPlotSynthesisCharacter
{
CharacterID = character.CharacterID,
CanonicalName = character.CanonicalName,
Aliases = aliasesByCharacter.GetValueOrDefault(character.CharacterID) ?? []
})
.ToList();
var threadsByPlotLine = threads
.GroupBy(thread => thread.PlotLineID)
.ToDictionary(
group => group.Key,
group => (IReadOnlyList<WholeBookPlotSynthesisExistingThread>)group.Select(thread => new WholeBookPlotSynthesisExistingThread
{
PlotThreadID = thread.PlotThreadID,
Title = thread.Title,
Summary = thread.Summary,
Status = thread.Status
}).ToList());
plotLines = plotLines
.Select(plotLine => new WholeBookPlotSynthesisExistingPlotLine
{
PlotLineID = plotLine.PlotLineID,
Name = plotLine.Name,
Description = plotLine.Description,
Importance = plotLine.Importance,
BookID = plotLine.BookID,
Threads = threadsByPlotLine.GetValueOrDefault(plotLine.PlotLineID) ?? []
})
.ToList();
var chapters = sourceRows
.GroupBy(row => new { row.ChapterID, row.ChapterNumber, row.ChapterTitle })
.Select(group => new WholeBookPlotSynthesisChapter
{
ChapterID = group.Key.ChapterID,
ChapterNumber = group.Key.ChapterNumber,
ChapterTitle = group.Key.ChapterTitle,
Scenes = group.Select(row => new WholeBookPlotSynthesisScene
{
SceneID = row.SceneID,
SceneNumber = row.SceneNumber,
SceneTitle = row.SceneTitle,
StructuralSummary = row.StructuralSummary,
StructuralSetting = row.StructuralSetting,
PovCharacterName = row.PovCharacterName,
SourceText = row.SourceText
}).ToList()
})
.ToList();
return new WholeBookPlotSynthesisContext
{
ProjectID = projectId,
BookID = bookId,
BookTitle = book.BookTitle,
Chapters = chapters,
Characters = characters,
ExistingPlotLines = plotLines,
AuthorSeeds = seeds,
SourceAudit = audit
};
}
private static string CandidateKey(string type, string key, string title)
=> $"{type}:{(string.IsNullOrWhiteSpace(key) ? title : key).Trim().ToLowerInvariant()}";
private sealed class ContextBookRow
{
public string BookTitle { get; init; } = string.Empty;
}
private sealed class ContextSceneRow
{
public int ChapterID { get; init; }
public decimal ChapterNumber { get; init; }
public string ChapterTitle { get; init; } = string.Empty;
public int SceneID { get; init; }
public decimal SceneNumber { get; init; }
public string SceneTitle { get; init; } = string.Empty;
public string? StructuralSummary { get; init; }
public string? StructuralSetting { get; init; }
public string? PovCharacterName { get; init; }
public string SourceText { get; init; } = string.Empty;
}
private sealed class ContextCharacterAliasRow
{
public int CharacterID { get; init; }
public string Alias { get; init; } = string.Empty;
}
private sealed class WholeBookPlotSynthesisExistingThreadRow
{
public int PlotThreadID { get; init; }
public int PlotLineID { get; init; }
public string Title { get; init; } = string.Empty;
public string? Summary { get; init; }
public string? Status { get; init; }
}
}

View File

@ -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, summarise scenes, create story entities or infer canon. You are an assistant archivist for a novelist. You observe chapter structure only. You do not critique, rewrite, create story entities or infer canon.
Your only task is to identify likely scene boundaries in one chapter. 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.
## Operating Principles ## Operating Principles
@ -21,7 +21,9 @@ Your only task is to identify likely scene boundaries in one chapter.
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 JSON only. 6. Return strict JSON only.
7. Do not wrap JSON in markdown fences.
8. Do not include explanatory prose before or after the JSON object.
## Boundary Guidance ## Boundary Guidance
@ -86,9 +88,25 @@ 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 Lena` 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:
@ -110,6 +128,8 @@ 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:
@ -117,10 +137,27 @@ 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 `Mara's car at night`, `church office`, or `archive 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:
@ -133,7 +170,7 @@ Do not output:
- assets; - assets;
- relationships; - relationships;
- metrics; - metrics;
- scene summaries; - interpretive scene analysis;
- observations; - observations;
- continuity warnings; - continuity warnings;
- plot threads. - plot threads.
@ -144,11 +181,22 @@ 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": "Lena",
"confidence": 0.96,
"evidence": "The chapter begins with an explicit Narrated by Lena 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."
} }

View File

@ -31,7 +31,7 @@ The response must match this shape:
"schemaVersion": "22", "schemaVersion": "22",
"updates": [ "updates": [
{ {
"canonicalName": "Maggie", "canonicalName": "Mara",
"aliases": ["Miss Jones"], "aliases": ["Miss Jones"],
"recurrenceSignal": "Appears again with the same role and appearance evidence.", "recurrenceSignal": "Appears again with the same role and appearance evidence.",
"confidence": 0.82, "confidence": 0.82,
@ -97,4 +97,3 @@ CURRENT_STORY_MEMORY_JSON:
SCENE_RESULT_ID: SCENE_RESULT_ID:
{{SCENE_RESULT_ID}} {{SCENE_RESULT_ID}}

View File

@ -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. Keep summaries neutral and descriptive. 4. Use supplied structural summaries as context rather than regenerating them.
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,11 +77,23 @@ It should not:
Use only the scene text and supplied context. Use only the scene text and supplied context.
When `knownCharacters` is supplied in the scene context, use it only as identity context for names already detected before analysis: 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 `sceneCharacters` is supplied in the scene context, use it only as identity context for characters already mapped to this canonical scene before analysis:
- match scene character names against `canonicalName` and `aliases`; - match scene character names against `canonicalName` and `aliases`;
- when the scene uses a supplied alias for the same identity, use the supplied `canonicalName` and include the scene-used name in `aliases`; - when the scene uses a supplied alias for the same identity, use the supplied `canonicalName` and include the scene-used name in `aliases`;
- do not create a character solely because it appears in `knownCharacters`; - do not create a character solely because it appears in `sceneCharacters`;
- do not override explicit scene evidence with scan context. - do not override explicit scene evidence with scan context.
If something is not stated or strongly implied: If something is not stated or strongly implied:
@ -110,18 +122,18 @@ Do not convert absence, memory, longing, grief, speculation, comparison or emoti
Bad unless explicitly stated by the supplied scene text: Bad unless explicitly stated by the supplied scene text:
- `Beth leaves Maggie behind.` - `Mara leaves Lena behind.`
- `Maggie is recently left by Beth.` - `Lena is recently left by Mara.`
- `Maggie is at the Ashdown Trust.` - `Lena is at the archive office.`
- `Beth travels from Maggie's location.` - `Mara travels from Lena's location.`
Good when supported by reflective or emotional text: Good when supported by reflective or emotional text:
- `Beth notices Maggie's absence.` - `Mara notices Lena's absence.`
- `Beth thinks about Maggie.` - `Mara thinks about Lena.`
- `Beth feels the car is too quiet without Maggie.` - `Mara feels the car is too quiet without Lena.`
- `Beth wonders whether she may be closer to finding Maggie.` - `Mara wonders whether she may be closer to finding Lena.`
- `Maggie is mentioned only in Beth's thoughts.` - `Lena is mentioned only in Mara's thoughts.`
If the text suggests a character is absent, remembered, missed, discussed, imagined or speculated about, preserve that limitation. Prefer ambiguity notes, questions raised and low-confidence speculation over invented literal facts. If the text suggests a character is absent, remembered, missed, discussed, imagined or speculated about, preserve that limitation. Prefer ambiguity notes, questions raised and low-confidence speculation over invented literal facts.
@ -142,70 +154,29 @@ 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 from 1 to 10. They are not quality ratings. Metrics are descriptive intensity scores. They are not quality ratings.
Use: Use the `configuredMetrics` array supplied in `sceneContext`. Do not invent a separate metric catalogue.
- `1`: barely present; For each configured metric:
- `2`: very low;
- `3`: low;
- `4`: mild;
- `5`: moderate;
- `6`: noticeable;
- `7`: strong;
- `8`: very strong;
- `9`: dominant;
- `10`: defining feature of the scene.
Preferred V1 metrics: - use the exact `key` supplied in `sceneContext` as the `key` value in `metricValues`;
- 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.
- `action` If no configured metrics are supplied, return an empty `metricValues` array.
- `emotion`
- `tension`
- `conflict`
- `mystery`
- `romance`
- `humour`
- `revelation`
- `pacingIntensity`
Each metric must be an object: Each metric value must contain `key`, `score` and `confidence`.
```json 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 values.
{ "score": 8, "confidence": 0.75 }
```
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
@ -219,7 +190,7 @@ Report characters who are:
For each character: For each character:
- use the manuscript's name or label; - use the manuscript's name or label;
- when supplied `knownCharacters` identify that name as an alias, use the supplied canonical name and place the alias used in the scene in `aliases`; - when supplied `sceneCharacters` identify that name as an alias, use the supplied canonical name and place the alias used in the scene in `aliases`;
- include exactly these properties: `name`, `roleInScene`, `mentionedOnly`, `aliases`, `actions`, `confidence`, `notes`; - include exactly these properties: `name`, `roleInScene`, `mentionedOnly`, `aliases`, `actions`, `confidence`, `notes`;
- set `mentionedOnly` to `false` when active in the scene; - set `mentionedOnly` to `false` when active in the scene;
- set `mentionedOnly` to `true` when absent and only referred to; - set `mentionedOnly` to `true` when absent and only referred to;
@ -242,7 +213,7 @@ Mentioned-only characters matter because later stages need to know who is refere
Scene understanding is not enough for illustration matching. Report explicit or strongly supported visual facts in `characterAppearance`. Scene understanding is not enough for illustration matching. Report explicit or strongly supported visual facts in `characterAppearance`.
For every named or recurring character with supported appearance evidence, include exactly these properties: `canonicalName`, `aliases`, `title`, `presentation`, `explicitAge`, `inferredAgeBand`, `hairColour`, `hairLength`, `hairStyle`, `facialHair`, `glasses`, `build`, `distinctiveFeatures`, `clothing`, `roleOrOccupation`, `relationshipAgeEvidence`, `pronounEvidence`, `evidence`, `confidence`, `evidenceKind`. For every named or recurring character with supported appearance evidence, include exactly these properties: `canonicalName`, `aliases`, `title`, `presentation`, `explicitAge`, `inferredAgeBand`, `hairColour`, `hairLength`, `hairStyle`, `eyeColour`, `facialHair`, `glasses`, `height`, `build`, `distinctiveFeatures`, `clothing`, `roleOrOccupation`, `relationshipAgeEvidence`, `pronounEvidence`, `evidence`, `confidence`, `evidenceKind`.
Rules: Rules:
@ -283,24 +254,31 @@ For each location, include exactly these properties: `name`, `locationType`, `ge
Use the `name` property for both named locations and generic labels. For an unnamed generic place, use the generic label as `name`, such as `"stair"` or `"doorway"`, and also populate `genericRoomType` when supported. Use `presentInScene: true` when the action occurs there. Use `mentionedOnly: true` only when the location is referred to but not used as the scene's action space. Use `notes: null` when no note is needed. Use the `name` property for both named locations and generic labels. For an unnamed generic place, use the generic label as `name`, such as `"stair"` or `"doorway"`, and also populate `genericRoomType` when supported. Use `presentInScene: true` when the action occurs there. Use `mentionedOnly: true` only when the location is referred to but not used as the scene's action space. Use `notes: null` when no note is needed.
Treat `sceneContext.currentScene.structuralSetting` as the primary established location clue when it is present. If the scene text says `my bedroom`, `the kitchen`, `upstairs`, `the house` or similar and the structural setting already identifies the containing place, report the contextual place rather than a separate bare generic location. Use canonical POV context to resolve first-person ownership such as `my bedroom`; do not output `Narrator's home` when a POV character is supplied.
Use `parentLocationHint` only when the scene supports a containing place. Do not invent location hierarchy. Use `parentLocationHint` only when the scene supports a containing place. Do not invent location hierarchy.
Prefer the active scene location over nearby route or address wording. If the action happens in a house, shop, office, garage, waiting room, test centre, car park, vehicle interior or other functional place that is described by reference to a road, report the functional place as the present location and put the road/address wording in `parentLocationHint` or `notes` only when the scene supports it. Report a road, lane or street as the present location only when the action space is actually the road, lane or street. Prefer the active scene location over nearby route or address wording. If the action happens in a house, shop, office, garage, waiting room, test centre, car park, vehicle interior or other functional place that is described by reference to a road, report the functional place as the present location and put the road/address wording in `parentLocationHint` or `notes` only when the scene supports it. Report a road, lane or street as the present location only when the action space is actually the road, lane or street.
Do not infer a character's current location from another character thinking about them. Do not infer a character's current location from another character thinking about them.
Example: if Beth thinks `closer, perhaps, to Maggie`, do not create `Maggie at Ashdown Trust`, `Maggie's location nearby` or `Beth leaves Maggie's location`. Instead, use a question or observation such as `Beth speculates that the Ashdown Trust may bring her closer to Maggie.` Example: if Mara thinks `closer, perhaps, to Lena`, do not create `Lena at the archive office`, `Lena's location nearby` or `Mara leaves Lena's location`. Instead, use a question or observation such as `Mara speculates that the archive office may bring her closer to Lena.`
## Point Of View Rules ## Point Of View Rules
For first-person scenes, if the scene text does not explicitly name the narrator: For first-person scenes, canonical PlotDirector POV metadata is authoritative when supplied in `sceneContext.currentScene.povCharacterName`.
- use `pointOfView.characterName: "Narrator"` or `null`; If canonical POV metadata is supplied:
- 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.
PlotDirector may resolve narrator identity later from chapter narrator metadata. Scene Intelligence should not force narrator identity where the scene does not provide it. - use that character name for `pointOfView.characterName`;
- cite the PlotDirector scene metadata in `evidence`;
- do not create `Narrator` as a separate character.
If canonical POV metadata is not supplied and the scene text does not explicitly name the narrator:
- use `pointOfView.characterName: null`;
- preserve ambiguity in `sourceLimits.ambiguityNotes`;
- do not create `Narrator` as a character.
## Asset Reporting Rules ## Asset Reporting Rules
@ -311,6 +289,7 @@ Report an object as an asset when it is:
- sought, lost, hidden, stolen or exchanged; - sought, lost, hidden, stolen or exchanged;
- linked to a story question; - linked to a story question;
- important to a character's factual action or knowledge; - important to a character's factual action or knowledge;
- named in `currentScene.structuralSummary` or the scene title as part of the scene's meaningful action;
- likely to recur as a story object. - likely to recur as a story object.
Do not report ordinary scenery or props unless the scene gives them story importance. Do not report ordinary scenery or props unless the scene gives them story importance.
@ -319,6 +298,8 @@ For each asset, include exactly these properties: `name`, `assetType`, `status`,
Use `assetType: null`, `status: null` or `ownerOrHolder: null` when unsupported. Use `mentionedOnly: false` when the asset is physically present or directly acted on in the scene. Use `notes: null` when no note is needed. Use `assetType: null`, `status: null` or `ownerOrHolder: null` when unsupported. Use `mentionedOnly: false` when the asset is physically present or directly acted on in the scene. Use `notes: null` when no note is needed.
Use canonical POV and `sceneCharacters` identity context for ownership. In a Mara POV scene, `my sealed notebook` should be reported as Mara's sealed notebook when the text or structural summary supports ownership. Do not turn every ordinary owned prop such as `my cup` or `my chair` into an asset unless the scene gives it story significance.
## Relationship Signal Rules ## Relationship Signal Rules
Report relationship signals only when the scene supports them. Report relationship signals only when the scene supports them.
@ -343,6 +324,8 @@ For each relationship signal, include exactly these properties: `characterA`, `c
Do not use property names such as `characters`, `signal`, `type`, `description` or `notes` in relationship objects. Use one relationship object per supported signal between two named characters. If the same pair has multiple supported signals, each object must still use `characterA`, `characterB`, `relationshipSignal`, `evidence` and `confidence`. Do not use property names such as `characters`, `signal`, `type`, `description` or `notes` in relationship objects. Use one relationship object per supported signal between two named characters. If the same pair has multiple supported signals, each object must still use `characterA`, `characterB`, `relationshipSignal`, `evidence` and `confidence`.
Preserve relationship progression evidence without flattening it. Repeated trust, hugs, gifts, shared social plans and familiar support can be friendship evidence. Romantic attraction, courtship, proposal or engagement needs explicit romantic evidence. Do not mark an early scene as a final state merely because adjacent or later context suggests a later progression.
## Knowledge Change Rules ## Knowledge Change Rules
Report knowledge changes only when the scene clearly shows that a character gains, confirms, suspects or misunderstands something during the scene. Report knowledge changes only when the scene clearly shows that a character gains, confirms, suspects or misunderstands something during the scene.
@ -375,7 +358,9 @@ Rules:
- Include exactly these properties in every knowledge change: `recipientCharacter`, `knowledgeItem`, `changeType`, `sourceCharacter`, `sourceType`, `evidence`, `confidence`. - Include exactly these properties in every knowledge change: `recipientCharacter`, `knowledgeItem`, `changeType`, `sourceCharacter`, `sourceType`, `evidence`, `confidence`.
- Do not use `knowledgeChanges` for information the reader learns but no character learns. - Do not use `knowledgeChanges` for information the reader learns but no character learns.
- Do not repeat an existing fact in every scene; report a knowledge change when a character acquires, confirms, newly suspects or misunderstands it in this scene.
- Do not assume a character did not know something before unless the scene supports that. - Do not assume a character did not know something before unless the scene supports that.
- Keep subjective knowledge separate from objective identity. A canonical CharacterID may identify a person while a character in the story still only suspects or later discovers that identity.
- Use `sourceCharacter: null` when the source is not a character. - Use `sourceCharacter: null` when the source is not a character.
- Use `sourceType: null` when the source type is unsupported. - Use `sourceType: null` when the source type is unsupported.
- Keep `evidence` concise and scene-backed. - Keep `evidence` concise and scene-backed.
@ -417,6 +402,73 @@ 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.
When `existingThreads` is supplied in `sceneContext`:
- compare the scene's narrower thread-scale signal with supplied Thread `title`, `summary` and parent Plot Line context;
- if the scene clearly continues an existing Thread, set `matchingExistingThreadId` to that supplied `plotThreadId`;
- if no supplied Thread clearly matches, set `matchingExistingThreadId` to `null`;
- do not invent Thread ids.
For each narrative arc, include exactly these properties: `title`, `description`, `arcType`, `scale`, `changeType`, `significance`, `continuityKey`, `parentArcTitle`, `matchingExistingPlotLineId`, `matchingExistingThreadId`, `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.
Use concise `changeType` values: `Introduced`, `Developed`, `Complicated`, `Revealed`, `Escalated`, `Resolved`, `Reopened`, or `Mentioned`.
Use `significance: "major"` for a material story development, `significance: "moderate"` for a meaningful supporting development, and `significance: "minor"` only where the signal is useful evidence but not enough on its own.
Set `continuityKey` to a short stable concept label that would remain recognisable even if later scenes use different wording for the same narrative concern. Prefer the underlying concern or objective, not this scene's phrasing. Use `parentArcTitle` only for a thread-scale or secondary signal that appears to sit beneath a broader arc; otherwise use `null`.
Good:
```json
{
"title": "Lena's Disappearance",
"description": "Mara's search for Lena advances through absence, clues and investigation.",
"arcType": "disappearance",
"scale": "major",
"changeType": "Developed",
"significance": "major",
"continuityKey": "Lena disappearance",
"parentArcTitle": null,
"matchingExistingPlotLineId": null,
"matchingExistingThreadId": null,
"evidence": "Mara follows a clue that may explain where Lena 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.
@ -485,19 +537,19 @@ Observation rules:
Example source text: Example source text:
`The car felt too quiet without Maggie.` `The car felt too quiet without Lena.`
Correct character extraction: Correct character extraction:
```json ```json
{ {
"name": "Maggie", "name": "Lena",
"roleInScene": "mentioned", "roleInScene": "mentioned",
"mentionedOnly": true, "mentionedOnly": true,
"aliases": [], "aliases": [],
"actions": [], "actions": [],
"confidence": 0.9, "confidence": 0.9,
"notes": "Maggie is absent and remembered by the narrator." "notes": "Lena is absent and remembered by the narrator."
} }
``` ```
@ -507,207 +559,29 @@ Correct observation:
{ {
"observationType": "CharacterKnowledge", "observationType": "CharacterKnowledge",
"subjectEntityType": "Character", "subjectEntityType": "Character",
"subjectName": "Narrator", "subjectName": "Mara",
"objectEntityType": "Character", "objectEntityType": "Character",
"objectName": "Maggie", "objectName": "Lena",
"predicate": "noticesAbsenceOf", "predicate": "noticesAbsenceOf",
"description": "The narrator feels Maggie's absence in the car.", "description": "Mara feels Lena's absence in the car.",
"evidence": "The car felt too quiet without Maggie.", "evidence": "The car felt too quiet without Lena.",
"confidence": 0.9 "confidence": 0.9
} }
``` ```
Incorrect: Incorrect:
- `Beth leaves Maggie behind.` - `Mara leaves Lena behind.`
- `Maggie was recently left by Beth.` - `Lena was recently left by Mara.`
- `Maggie is located near the Ashdown Trust.` - `Lena is located near the archive office.`
## JSON Output Requirements ## JSON Output Requirements
Return exactly one JSON object matching this shape: Return exactly one JSON object that satisfies the API-provided `scene_intelligence_v1` structured-output schema.
```json Use `[]` for any array with no supported items. Never output placeholder objects with empty required strings.
{
"schemaVersion": "1.0",
"sceneReference": {
"projectId": null,
"bookId": null,
"chapterId": null,
"sceneId": null,
"chapterNumber": null,
"sceneNumber": null,
"sourceLabel": ""
},
"summary": {
"short": "",
"detailed": "",
"confidence": 0.0
},
"scenePurpose": {
"observedFunction": "",
"confidence": 0.0
},
"pointOfView": {
"characterName": null,
"narrativeMode": null,
"confidence": 0.0,
"evidence": null
},
"setting": {
"timeOfDay": null,
"dateOrTimeReference": null,
"locationName": null,
"locationType": "unclear",
"genericRoomType": null,
"parentLocationHint": null,
"confidence": 0.0
},
"characters": [
{
"name": "",
"roleInScene": "unclear",
"mentionedOnly": false,
"aliases": [],
"actions": [],
"confidence": 0.0,
"notes": null
}
],
"characterAppearance": [
{
"canonicalName": "",
"aliases": [],
"title": null,
"presentation": null,
"explicitAge": null,
"inferredAgeBand": null,
"hairColour": null,
"hairLength": null,
"hairStyle": null,
"facialHair": null,
"glasses": null,
"build": null,
"distinctiveFeatures": [],
"clothing": null,
"roleOrOccupation": null,
"relationshipAgeEvidence": null,
"pronounEvidence": null,
"evidence": "",
"confidence": 0.0,
"evidenceKind": "explicit"
}
],
"locations": [
{
"name": "",
"locationType": "unclear",
"genericRoomType": null,
"parentLocationHint": null,
"presentInScene": false,
"mentionedOnly": false,
"confidence": 0.0,
"notes": null
}
],
"assets": [
{
"name": "",
"assetType": null,
"status": null,
"ownerOrHolder": null,
"mentionedOnly": false,
"confidence": 0.0,
"notes": null
}
],
"relationships": [
{
"characterA": "",
"characterB": "",
"relationshipSignal": "",
"evidence": "",
"confidence": 0.0
}
],
"knowledgeChanges": [
{
"recipientCharacter": "",
"knowledgeItem": "",
"changeType": "Learns",
"sourceCharacter": null,
"sourceType": null,
"evidence": "",
"confidence": 0.0
}
],
"timelineClues": [
{
"clue": "",
"relativeOrder": null,
"absoluteDate": null,
"evidence": "",
"confidence": 0.0
}
],
"questionsRaised": [
{
"question": "",
"scope": "plot",
"evidence": "",
"confidence": 0.0
}
],
"questionsAnswered": [
{
"question": "",
"answer": "",
"evidence": "",
"confidence": 0.0
}
],
"observations": [
{
"observationType": "CharacterAction",
"subjectEntityType": "Character",
"subjectName": "",
"objectEntityType": null,
"objectName": null,
"predicate": "observes",
"description": "",
"evidence": "",
"confidence": 0.0
}
],
"metrics": {
"action": { "score": 1, "confidence": 0.0 },
"emotion": { "score": 1, "confidence": 0.0 },
"tension": { "score": 1, "confidence": 0.0 },
"conflict": { "score": 1, "confidence": 0.0 },
"mystery": { "score": 1, "confidence": 0.0 },
"romance": { "score": 1, "confidence": 0.0 },
"humour": { "score": 1, "confidence": 0.0 },
"revelation": { "score": 1, "confidence": 0.0 },
"pacingIntensity": { "score": 1, "confidence": 0.0 }
},
"sourceLimits": {
"containsAmbiguity": false,
"ambiguityNotes": []
}
}
```
The example arrays above show object shape. In the actual response, use `[]` for any array with no supported items. Never output placeholder objects with empty required strings. All arrays must be present. Every object in an array must include every schema property for that object type. Every required object must be present. Unknown scalar values must be `null` only where the schema permits null. Unknown lists must be `[]`. Do not omit properties.
All arrays must be present. Every object in an array must include every property shown for that object type. Every required object must be present. Unknown scalar values must be `null` only where the schema permits null. Unknown lists must be `[]`. Do not omit properties.
Use valid JSON:
- double quotes for property names and strings;
- no trailing commas;
- no comments;
- no Markdown;
- no prose outside JSON.
## Runtime Data Insertion ## Runtime Data Insertion

View File

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

View File

@ -13,7 +13,8 @@ namespace PlotLine.Hubs;
public sealed class StoryIntelligenceHub( public sealed class StoryIntelligenceHub(
IStoryIntelligenceService storyIntelligence, IStoryIntelligenceService storyIntelligence,
IStoryIntelligenceResultRepository persistedRuns, IStoryIntelligenceResultRepository persistedRuns,
IOnboardingStoryIntelligenceService onboardingStoryIntelligence) : Hub IOnboardingStoryIntelligenceService onboardingStoryIntelligence,
IStoryIntelligenceGlobalProgressService globalProgress) : Hub
{ {
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
@ -43,8 +44,12 @@ public sealed class StoryIntelligenceHub(
return null; return null;
} }
var sceneResults = await persistedRuns.ListSceneResultsAsync(runId); var canonicalScenes = run.ChapterID.HasValue
return ToRunProgress(run, "Current status", LatestSceneSummary(sceneResults)); ? await persistedRuns.ListChapterScenesAsync(run.ChapterID.Value)
: [];
var currentScene = CurrentCanonicalScene(run, canonicalScenes);
var sceneResults = currentScene is null ? await persistedRuns.ListSceneResultsAsync(runId) : [];
return ToRunProgress(run, "Current status", currentScene, currentScene is null ? LatestSceneSummary(sceneResults) : CanonicalSceneSummary(currentScene));
} }
public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId) public async Task<StoryIntelligenceProgressViewModel?> WatchOnboardingStoryIntelligenceBatch(Guid batchId)
@ -54,13 +59,23 @@ public sealed class StoryIntelligenceHub(
return await onboardingStoryIntelligence.GetProgressAsync(batchId); return await onboardingStoryIntelligence.GetProgressAsync(batchId);
} }
public async Task<StoryIntelligenceGlobalProgressViewModel> WatchGlobalStoryIntelligenceProgress()
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
return await globalProgress.GetForCurrentUserAsync();
}
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, string? latestSceneSummary) private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, Scene? currentScene, 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
{ {
@ -79,7 +94,11 @@ public sealed class StoryIntelligenceHub(
FailedScenes = run.FailedScenes, FailedScenes = run.FailedScenes,
TotalTokens = run.TotalTokens, TotalTokens = run.TotalTokens,
TotalDurationMs = elapsedMs, TotalDurationMs = elapsedMs,
CurrentSceneNumber = run.CompletedScenes, CurrentSceneID = currentScene?.SceneID,
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,
@ -90,6 +109,56 @@ 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));

View File

@ -4,15 +4,19 @@ 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;
[Authorize] [Authorize]
public sealed class WordCompanionFollowHub( public sealed class WordCompanionFollowHub(
IWordCompanionPresenceService presence, IWordCompanionPresenceService presence,
IWritingWorkspacePresenceService writingPresence,
IOnboardingRepository onboarding, IOnboardingRepository onboarding,
IManuscriptScanPreviewStore scanStore, IManuscriptScanPreviewStore scanStore,
IOnboardingService onboardingService) : Hub IOnboardingService onboardingService,
IOnboardingBuildRepository onboardingBuilds,
ILogger<WordCompanionFollowHub> logger) : Hub
{ {
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
@ -40,7 +44,14 @@ 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);
return status; return status;
} }
@ -48,10 +59,70 @@ 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);
return status; return status;
} }
public async Task<WritingWorkspaceStatus> WatchWritingWorkspace(WritingWorkspaceScope scope)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
logger.LogInformation(
"Writing workspace watched. UserID={UserID} ProjectID={ProjectID} BookID={BookID}",
userId,
scope.ProjectID,
scope.BookID);
return await WritingWorkspaceStatusAsync(userId, scope);
}
public async Task<WritingWorkspaceStatus> RegisterWritingWindow(WritingWindowRegistration registration)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
var status = await writingPresence.RegisterAsync(userId, Context.ConnectionId, registration, await presence.GetStatusAsync(userId));
logger.LogInformation(
"Writing window registered. UserID={UserID} Role={Role} ProjectID={ProjectID} BookID={BookID} Route={Route}",
userId,
registration.WritingRole,
registration.ProjectID,
registration.BookID,
registration.CurrentRoute);
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", status);
return status;
}
public async Task<WritingWorkspaceStatus> WritingWindowHeartbeat(WritingWindowHeartbeat heartbeat)
{
var userId = RequireUserId();
var status = await writingPresence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat, await presence.GetStatusAsync(userId));
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", status);
return status;
}
public async Task<WritingWorkspaceStatus> RequestWritingWindowFocus(WritingWorkspaceScope scope, string role)
{
var userId = RequireUserId();
var connectionId = await writingPresence.GetConnectionIdForRoleAsync(userId, scope, role);
if (!string.IsNullOrWhiteSpace(connectionId))
{
await Clients.Client(connectionId).SendAsync("WritingWorkspaceFocusRequested", new
{
WritingRole = WritingWorkspaceRoles.Normalise(role),
scope.ProjectID,
scope.BookID
});
}
return await WritingWorkspaceStatusAsync(userId, scope);
}
public async Task<ManuscriptScanState> StartOnboardingManuscriptScan() public async Task<ManuscriptScanState> StartOnboardingManuscriptScan()
{ {
var userId = RequireUserId(); var userId = RequireUserId();
@ -83,8 +154,17 @@ public sealed class WordCompanionFollowHub(
var scanState = await scanStore.StartAsync(userId, state.UserOnboardingStateID, state.ProjectID.Value, state.BookID.Value); var scanState = await scanStore.StartAsync(userId, state.UserOnboardingStateID, state.ProjectID.Value, state.BookID.Value);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanStateChanged", scanState); await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanStateChanged", scanState);
logger.LogInformation(
"Word Companion onboarding scan requested. UserID={UserID} OnboardingID={OnboardingID} ProjectID={ProjectID} BookID={BookID} ScanID={ScanID} CompanionConnectionID={CompanionConnectionID}",
userId,
state.UserOnboardingStateID,
state.ProjectID.Value,
state.BookID.Value,
scanState.ScanID,
companionConnectionId);
await Clients.Client(companionConnectionId).SendAsync("ScanCurrentDocument", new ManuscriptScanCommand await Clients.Client(companionConnectionId).SendAsync("ScanCurrentDocument", new ManuscriptScanCommand
{ {
ScanID = scanState.ScanID ?? Guid.NewGuid(),
UserID = userId, UserID = userId,
OnboardingID = state.UserOnboardingStateID, OnboardingID = state.UserOnboardingStateID,
ProjectID = state.ProjectID.Value, ProjectID = state.ProjectID.Value,
@ -94,6 +174,31 @@ public sealed class WordCompanionFollowHub(
return scanState; return scanState;
} }
public async Task<ManuscriptScanState> ReceiveOnboardingScanChunk(ManuscriptScanChunk chunk)
{
var userId = RequireUserId();
var scanState = await scanStore.AddChunkAsync(userId, chunk);
if (string.Equals(scanState.Status, ManuscriptScanStatuses.Complete, StringComparison.Ordinal))
{
logger.LogInformation(
"Word Companion onboarding scan completed from chunked payload. UserID={UserID} OnboardingID={OnboardingID} ScanID={ScanID} Chunks={Chunks} PayloadCharacters={PayloadCharacters} Words={Words} Chapters={Chapters}",
userId,
chunk.OnboardingID,
chunk.ScanID,
chunk.TotalChunks,
chunk.TotalLength,
scanState.TotalWordCount,
scanState.ChapterCount);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanCompleted", scanState);
}
else
{
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanProgress", scanState);
}
return scanState;
}
public async Task<ManuscriptScanState> ReportOnboardingScanProgress(ManuscriptScanProgress progress) public async Task<ManuscriptScanState> ReportOnboardingScanProgress(ManuscriptScanProgress progress)
{ {
var userId = RequireUserId(); var userId = RequireUserId();
@ -132,7 +237,7 @@ public sealed class WordCompanionFollowHub(
return scanState; return scanState;
} }
public async Task<OnboardingManuscriptBuildResult> StartOnboardingProjectBuild(Guid previewId) public async Task<OnboardingManuscriptBuildResult> StartOnboardingProjectBuild(Guid previewId, bool generateSceneTitles = true)
{ {
var userId = RequireUserId(); var userId = RequireUserId();
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
@ -143,9 +248,26 @@ public sealed class WordCompanionFollowHub(
PreviewID = previewId PreviewID = previewId
}); });
var result = await onboardingService.BuildApprovedStructureAsync( OnboardingManuscriptBuildResult? result;
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.");
@ -154,19 +276,125 @@ 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;
var status = await presence.DisconnectAsync(Context.ConnectionId); var status = await presence.DisconnectAsync(Context.ConnectionId);
if (status is not null) if (status is not null)
{ {
disconnectedUserId = status.UserID;
await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status); await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(status.UserID, status.LinkedProjectID, status.LinkedBookID);
var failedScans = await scanStore.FailRunningForUserAsync( var failedScans = await scanStore.FailRunningForUserAsync(
status.UserID, status.UserID,
"The Word Companion disconnected before the scan finished. Reopen Word and try again."); "The Word Companion disconnected before the scan finished. Reopen Word and try again.");
@ -176,9 +404,30 @@ public sealed class WordCompanionFollowHub(
} }
} }
var writingStatus = await writingPresence.DisconnectAsync(
Context.ConnectionId,
disconnectedUserId.HasValue ? await presence.GetStatusAsync(disconnectedUserId.Value) : null);
if (writingStatus is not null)
{
await Clients.Group(PresenceGroup(writingStatus.UserID)).SendAsync("WritingWorkspacePresenceChanged", writingStatus);
}
await base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
} }
private async Task<WritingWorkspaceStatus> WritingWorkspaceStatusAsync(int userId, WritingWorkspaceScope scope)
=> await writingPresence.GetStatusAsync(userId, scope, await presence.GetStatusAsync(userId));
private async Task BroadcastWritingWorkspaceStatusAsync(int userId, int? projectId, int? bookId)
{
var workspaceStatus = await WritingWorkspaceStatusAsync(userId, new WritingWorkspaceScope
{
ProjectID = projectId,
BookID = bookId
});
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", workspaceStatus);
}
private int RequireUserId() private int RequireUserId()
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use the Word Companion."); => TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use the Word Companion.");
@ -191,8 +440,15 @@ 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)
=> new() {
return new()
{ {
PreviewID = preview.PreviewID, PreviewID = preview.PreviewID,
UserID = preview.UserID == 0 ? userId : preview.UserID, UserID = preview.UserID == 0 ? userId : preview.UserID,
@ -204,11 +460,30 @@ public sealed class WordCompanionFollowHub(
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier, CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
TotalWordCount = preview.TotalWordCount, TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount, ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount, SceneCount = 0,
CharacterCandidateCount = preview.CharacterCandidateCount, CharacterCandidateCount = 0,
CreatedUtc = preview.CreatedUtc, CreatedUtc = preview.CreatedUtc,
Chapters = preview.Chapters, Chapters = preview.Chapters,
Scenes = preview.Scenes, Scenes = [],
CharacterCandidates = preview.CharacterCandidates CharacterCandidates = []
}; };
}
}
public sealed class MarkerSyncClientResult
{
public Guid? PreviewID { get; init; }
public string? MarkerStatus { get; init; }
public string? Message { get; init; }
public bool? MarkersChanged { get; init; }
public string? SaveState { get; init; }
public string? SaveApi { get; init; }
public string? Host { get; init; }
public string? Platform { get; init; }
public string? Version { get; init; }
public string? DocumentGuid { get; init; }
public string? DocumentUrl { get; init; }
public string? ErrorName { get; init; }
public string? ErrorCode { get; init; }
public string? ErrorMessage { get; init; }
} }

View File

@ -7,6 +7,8 @@ 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]
@ -18,9 +20,21 @@ 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; }
}

View File

@ -235,6 +235,8 @@ 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; }
@ -255,6 +257,53 @@ 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; }
@ -344,6 +393,11 @@ 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; }
@ -376,8 +430,16 @@ 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; }
@ -419,6 +481,7 @@ 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
{ {
@ -434,6 +497,25 @@ 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; }
@ -686,6 +768,10 @@ public sealed class ThreadEvent
public IReadOnlyList<int> TargetPlotLineIDs { get; set; } = []; public IReadOnlyList<int> TargetPlotLineIDs { get; set; } = [];
public string EventTitle { get; set; } = string.Empty; public string EventTitle { get; set; } = string.Empty;
public string? EventDescription { get; set; } public string? EventDescription { get; set; }
public string Source { get; set; } = "Author";
public int? SourceReferenceID { get; set; }
public string? SourceKey { get; set; }
public decimal? Confidence { get; set; }
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
} }
@ -930,6 +1016,14 @@ 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; }
@ -997,6 +1091,10 @@ 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; }

View File

@ -34,6 +34,7 @@ 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)
{ {
@ -45,7 +46,8 @@ 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)
@ -60,10 +62,13 @@ 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
@ -95,6 +100,18 @@ 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()
{ {
@ -173,6 +190,28 @@ 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; }
@ -200,6 +239,7 @@ 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
@ -229,6 +269,9 @@ 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]

View File

@ -19,10 +19,12 @@ 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
{ {
public Guid ScanID { get; init; }
public int UserID { get; init; } public int UserID { get; init; }
public int OnboardingID { get; init; } public int OnboardingID { get; init; }
public int ProjectID { get; init; } public int ProjectID { get; init; }
@ -49,6 +51,17 @@ public sealed class ManuscriptScanFailure
public string Message { get; init; } = string.Empty; public string Message { get; init; } = string.Empty;
} }
public sealed class ManuscriptScanChunk
{
public Guid ScanID { get; init; }
public int? UserID { get; init; }
public int OnboardingID { get; init; }
public int ChunkIndex { get; init; }
public int TotalChunks { get; init; }
public int TotalLength { get; init; }
public string Payload { get; init; } = string.Empty;
}
public sealed class ManuscriptScanPreview public sealed class ManuscriptScanPreview
{ {
public Guid PreviewID { get; init; } = Guid.NewGuid(); public Guid PreviewID { get; init; } = Guid.NewGuid();
@ -78,6 +91,26 @@ 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
@ -102,10 +135,22 @@ 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
{ {
public Guid? ScanID { get; init; }
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted; public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete; public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
public string Message { get; init; } = string.Empty; public string Message { get; init; } = string.Empty;
@ -122,6 +167,7 @@ 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; } = [];
@ -176,9 +222,33 @@ 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;
@ -189,6 +259,8 @@ 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
@ -198,6 +270,16 @@ 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";
@ -208,4 +290,6 @@ 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; }
} }

View File

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

View File

@ -18,10 +18,12 @@ 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; }
public Dictionary<string, SceneIntelligenceMetric>? Metrics { get; init; } public Dictionary<string, SceneIntelligenceMetric>? Metrics { get; init; }
public List<SceneIntelligenceMetricValue>? MetricValues { get; init; }
public SceneIntelligenceSourceLimits? SourceLimits { get; init; } public SceneIntelligenceSourceLimits? SourceLimits { get; init; }
[JsonExtensionData] [JsonExtensionData]
@ -207,6 +209,25 @@ 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 string? ChangeType { get; init; }
public string? Significance { get; init; }
public string? ContinuityKey { get; init; }
public string? ParentArcTitle { get; init; }
public int? MatchingExistingPlotLineId { get; init; }
public int? MatchingExistingThreadId { 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; }
@ -243,6 +264,16 @@ public sealed class SceneIntelligenceMetric
public Dictionary<string, JsonElement>? ExtensionData { get; init; } public Dictionary<string, JsonElement>? ExtensionData { get; init; }
} }
public sealed class SceneIntelligenceMetricValue
{
public string? Key { get; init; }
public int? Score { get; init; }
public decimal? Confidence { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
}
public sealed class SceneIntelligenceSourceLimits public sealed class SceneIntelligenceSourceLimits
{ {
public bool? ContainsAmbiguity { get; init; } public bool? ContainsAmbiguity { get; init; }

View File

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

View File

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

View File

@ -82,7 +82,9 @@ 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; }
@ -99,15 +101,31 @@ 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; } = string.Empty; public string WholeBookPlotSynthesisModel { get; init; } = string.Empty;
public string ImageGenerationModel { get; init; } = "gpt-image-1.5";
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? WholeBookPlotSynthesisMaxOutputTokens { get; init; }
public int TimeoutSeconds { get; init; } = 120; public int TimeoutSeconds { get; init; } = 120;
public int MaxGlobalConcurrentAiRequests { get; init; } = 4;
public int MaxConcurrentAiRequestsPerBook { get; init; } = 2;
public int MaxConcurrentAiRequestsPerUser { get; init; } = 3;
public int QueuePollingIntervalSeconds { get; init; } = 3;
public int ClaimLeaseMinutes { get; init; } = 90;
public string EffectiveModel => FirstConfigured(Model); public string EffectiveModel => FirstConfigured(Model);
public string EffectiveChapterStructureModel => FirstConfigured(ChapterStructureModel, Model); public string EffectiveChapterStructureModel => FirstConfigured(ChapterStructureModel, Model);
public string EffectiveSceneIntelligenceModel => FirstConfigured(SceneIntelligenceModel, Model); public string EffectiveSceneIntelligenceModel => FirstConfigured(SceneIntelligenceModel, Model);
public string EffectiveWholeBookPlotSynthesisModel => FirstConfigured(WholeBookPlotSynthesisModel, Model);
public static string BuildModelSummary(string? chapterStructureModel, string? sceneIntelligenceModel) public static string BuildModelSummary(string? chapterStructureModel, string? sceneIntelligenceModel)
=> $"Chapter={FirstConfigured(chapterStructureModel)}; Scene={FirstConfigured(sceneIntelligenceModel)}"; => $"Chapter={FirstConfigured(chapterStructureModel)}; Scene={FirstConfigured(sceneIntelligenceModel)}";
@ -221,3 +239,10 @@ public sealed class StoryIntelligenceClientResult
public int? OutputTokens { get; init; } public int? OutputTokens { get; init; }
public decimal? EstimatedCost { get; init; } public decimal? EstimatedCost { get; init; }
} }
public sealed class StoryIntelligenceResponseContract
{
public string Name { get; init; } = string.Empty;
public object Schema { get; init; } = new();
public bool Strict { get; init; } = true;
}

View File

@ -0,0 +1,135 @@
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 MergeCandidate = "MergeCandidate";
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 string? ChangeType { get; set; }
public string? Significance { get; set; }
public int? ExistingPlotLineID { get; set; }
public string? ExistingPlotLineName { get; set; }
public int? ExistingPlotThreadID { get; set; }
public string? ExistingPlotThreadTitle { get; set; }
public int? WholeBookPlotSynthesisRunID { get; set; }
public int? AuthorPlotSeedID { get; set; }
public string? AuthorPlotSeedDescription { get; set; }
public string? AuthorPlotSeedSource { get; set; }
public decimal? ExistingPlotLineMatchConfidence { get; set; }
public string? ExistingPlotLineMatchReason { get; set; }
public decimal? SeedMatchConfidence { get; set; }
public string? SeedMatchReason { get; set; }
public string? Source { get; set; }
public int? RelatedPlotLineID { get; set; }
public string? RelatedPlotLineName { get; set; }
public string? ParentCandidateKey { get; set; }
public int? ParentNarrativeCandidateID { get; set; }
public string? ParentCandidateTitle { get; set; }
public int? MergeTargetNarrativeCandidateID { 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 IReadOnlyList<StoryIntelligenceNarrativeCandidateEvent> Events { 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 string? ChangeType { get; init; }
public string? Significance { get; init; }
public int? ExistingPlotLineID { get; init; }
public int? ExistingPlotThreadID { get; init; }
public int? WholeBookPlotSynthesisRunID { get; init; }
public int? AuthorPlotSeedID { get; init; }
public decimal? ExistingPlotLineMatchConfidence { get; init; }
public string? ExistingPlotLineMatchReason { get; init; }
public decimal? SeedMatchConfidence { get; init; }
public string? SeedMatchReason { get; init; }
public string? Source { get; init; }
public int? RelatedPlotLineID { get; init; }
public string? ParentCandidateKey { get; init; }
public int? ParentNarrativeCandidateID { get; init; }
public decimal? Confidence { get; init; }
}
public sealed class StoryIntelligenceNarrativeCandidateEvent
{
public int NarrativeCandidateEventID { get; set; }
public int NarrativeCandidateID { get; set; }
public int? WholeBookPlotSynthesisRunID { get; set; }
public string EventKey { get; set; } = string.Empty;
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 EventType { get; set; } = string.Empty;
public string EventTitle { get; set; } = string.Empty;
public string? EventDescription { get; set; }
public string? Evidence { get; set; }
public decimal? Confidence { get; set; }
public int SortOrder { get; set; }
public bool IsIgnored { get; set; }
public int? AcceptedThreadEventID { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
}

View File

@ -36,6 +36,8 @@ 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";
} }
@ -60,13 +62,68 @@ public sealed class StoryIntelligenceBookPipelineState
public string? CurrentReviewStage { get; init; } public string? CurrentReviewStage { get; init; }
public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview; public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview;
public DateTime? CompletedUtc { get; init; } public DateTime? CompletedUtc { get; init; }
public DateTime? CharacterReviewCompletedUtc { get; init; }
public DateTime? LocationReviewCompletedUtc { get; init; }
public DateTime? AssetReviewCompletedUtc { get; init; }
public DateTime? RelationshipReviewCompletedUtc { get; init; }
public DateTime? KnowledgeReviewCompletedUtc { get; init; }
public DateTime? NarrativeReviewCompletedUtc { get; init; }
public int? LastRunID { get; init; } public int? LastRunID { get; init; }
public DateTime CreatedUtc { get; init; } public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; } public DateTime UpdatedUtc { get; init; }
public bool IsComplete public bool IsComplete
=> string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) => string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase); && string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.NarrativeImport, StringComparison.OrdinalIgnoreCase);
}
public sealed class StoryIntelligenceImportNotificationTarget
{
public int ImportSessionID { get; init; }
public int UserID { get; init; }
public int ProjectID { get; init; }
public string ProjectName { get; init; } = string.Empty;
public int BookID { get; init; }
public string BookTitle { get; init; } = string.Empty;
public string? BookSubtitle { get; init; }
public string BookDisplayTitle => BookTitleFormatter.DisplayTitle(BookTitle, BookSubtitle);
public int ChapterCount { get; init; }
public int CompletedChapterCount { get; init; }
public int FailedChapterCount { get; init; }
public string NotificationKind { get; init; } = "Success";
}
public sealed class StoryIntelligenceSceneAnalysisCompletion
{
public int StoryIntelligenceBookPipelineID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int UserID { get; init; }
public int? LastRunID { get; init; }
public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview;
public string CurrentStage { get; init; } = StoryIntelligencePipelineStages.CharacterReview;
public string? LastCompletedStage { get; init; }
public string? CurrentReviewStage { get; init; }
public int RunCount { get; init; }
public int CompletedRunCount { get; init; }
public int FailedRunCount { get; init; }
public int TotalExpectedScenes { get; init; }
public int CompletedScenes { get; init; }
public int FailedScenes { get; init; }
public bool CompletedNow { get; init; }
}
public sealed class StoryIntelligenceSceneAnalysisRecoveryCandidate
{
public int StoryIntelligenceBookPipelineID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int UserID { get; init; }
public int? LastRunID { get; init; }
public int RunCount { get; init; }
public int TotalExpectedScenes { get; init; }
public int CompletedScenes { get; init; }
public int FailedScenes { get; init; }
} }
public sealed class StoryIntelligenceBookPipelineSaveRequest public sealed class StoryIntelligenceBookPipelineSaveRequest
@ -79,6 +136,49 @@ public sealed class StoryIntelligenceBookPipelineSaveRequest
public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview; public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview;
public DateTime? CompletedUtc { get; init; } public DateTime? CompletedUtc { get; init; }
public int? LastRunID { get; init; } public int? LastRunID { get; init; }
public bool MarkCharacterReviewComplete { get; init; }
public bool MarkLocationReviewComplete { get; init; }
public bool MarkAssetReviewComplete { get; init; }
public bool MarkRelationshipReviewComplete { get; init; }
public bool MarkKnowledgeReviewComplete { get; init; }
public bool MarkNarrativeReviewComplete { get; init; }
}
public sealed class StoryIntelligenceReviewCentreSummary
{
public int StoryIntelligenceBookPipelineID { 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 string CurrentStage { get; init; } = StoryIntelligencePipelineStages.SceneReview;
public string? LastCompletedStage { get; init; }
public string? CurrentReviewStage { get; init; }
public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview;
public DateTime? CompletedUtc { get; init; }
public DateTime? CharacterReviewCompletedUtc { get; init; }
public DateTime? LocationReviewCompletedUtc { get; init; }
public DateTime? AssetReviewCompletedUtc { get; init; }
public DateTime? RelationshipReviewCompletedUtc { get; init; }
public DateTime? KnowledgeReviewCompletedUtc { get; init; }
public DateTime? NarrativeReviewCompletedUtc { get; init; }
public int? LastRunID { get; init; }
public int RunCount { get; init; }
public int CompletedRunCount { get; init; }
public int ActiveRunCount { get; init; }
public int FailedRunCount { get; init; }
public int TotalDetectedScenes { get; init; }
public int CompletedScenes { get; init; }
public int FailedScenes { get; init; }
public int WholeBookPlotSynthesisRunID { get; init; }
public string? WholeBookPlotSynthesisStatus { get; init; }
public string? WholeBookPlotSynthesisErrorMessage { get; init; }
public int NarrativeCandidateCount { get; init; }
public int NarrativePendingCount { get; init; }
public int NarrativeThreadCount { get; init; }
public int NarrativeEventCount { get; init; }
} }
public sealed class StoryIntelligenceCommittedRunSummary public sealed class StoryIntelligenceCommittedRunSummary
@ -159,6 +259,7 @@ 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";
@ -321,6 +422,35 @@ public sealed class StoryIntelligenceQueuedRun
public bool CancellationRequested => CancellationRequestedUtc.HasValue; public bool CancellationRequested => CancellationRequestedUtc.HasValue;
} }
public sealed class StoryIntelligenceActiveBookRunSummary
{
public int UserID { get; init; }
public int? ProjectID { get; init; }
public string? ProjectTitle { get; init; }
public int? BookID { get; init; }
public string? BookTitle { get; init; }
public string? BookSubtitle { get; init; }
public string BookDisplayTitle => BookTitleFormatter.DisplayTitle(BookTitle, BookSubtitle);
public int RunCount { get; init; }
public int PendingRunCount { get; init; }
public int RunningRunCount { get; init; }
public int CompletedRunCount { get; init; }
public int FailedRunCount { get; init; }
public int TotalExpectedScenes { get; init; }
public int TotalDetectedScenes { get; init; }
public int CompletedScenes { get; init; }
public int FailedScenes { get; init; }
public int ActiveScenes { get; init; }
public int ProgressPercent { get; init; }
public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending;
public long TotalDurationMs { get; init; }
public DateTime FirstCreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
public bool HasActiveRuns => PendingRunCount > 0 || RunningRunCount > 0;
public bool HasFailures => FailedRunCount > 0 || FailedScenes > 0;
}
public sealed class StoryIntelligenceSourceOption public sealed class StoryIntelligenceSourceOption
{ {
public int Value { get; init; } public int Value { get; init; }
@ -472,6 +602,7 @@ 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; }
@ -485,6 +616,8 @@ 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

View File

@ -220,6 +220,9 @@ 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;
@ -230,6 +233,9 @@ 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;

View File

@ -0,0 +1,59 @@
using PlotLine.ViewModels;
namespace PlotLine.Models;
public sealed record StoryPosition(int ProjectId, int? BookId = null, int? ChapterId = null, int? SceneId = null)
{
public bool IsValid => ProjectId > 0 && (BookId is null or > 0) && (ChapterId is null or > 0) && (SceneId is null or > 0);
public bool IsScenePosition => SceneId.HasValue;
public static StoryPosition? FromScene(SceneEditViewModel? scene)
{
if (scene?.Project is null)
{
return null;
}
return new StoryPosition(scene.Project.ProjectID, scene.Book?.BookID, scene.ChapterID, scene.SceneID > 0 ? scene.SceneID : null);
}
public static StoryPosition? FromScene(Scene? scene, int projectId)
{
if (scene is null || projectId <= 0)
{
return null;
}
return new StoryPosition(projectId, scene.BookID > 0 ? scene.BookID : null, scene.ChapterID, scene.SceneID);
}
public static StoryPosition? FromRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneRequest request)
{
var position = new StoryPosition(request.ProjectId, request.BookId, request.ChapterId, request.SceneId);
return position.IsValid ? position : null;
}
public static StoryPosition? FromRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneResponse? response)
{
if (response is null)
{
return null;
}
var position = new StoryPosition(response.ProjectId, response.BookId, response.ChapterId, response.SceneId);
return position.IsValid ? position : null;
}
public static StoryPosition? FromTimelineSelection(int projectId, int? bookId, int? chapterId, int? sceneId)
{
if (projectId <= 0)
{
return null;
}
var position = new StoryPosition(projectId, bookId, chapterId, sceneId);
return position.IsValid ? position : null;
}
public string? SceneEditUrl => SceneId.HasValue ? $"/Scenes/Edit/{SceneId.Value}" : null;
}

View File

@ -0,0 +1,211 @@
using System.Text.Json;
namespace PlotLine.Models;
public static class WholeBookPlotSynthesisStatuses
{
public const string Queued = "Queued";
public const string Running = "Running";
public const string Completed = "Completed";
public const string Failed = "Failed";
public const string Superseded = "Superseded";
}
public static class WholeBookPlotSynthesisSources
{
public const string Author = "Author";
public const string WholeBook = "WholeBookPlotSynthesis";
public const string SceneSignals = "SceneSignals";
}
public sealed class AuthorPlotSeed
{
public int AuthorPlotSeedID { get; set; }
public int ProjectID { get; set; }
public int BookID { get; set; }
public string Description { get; set; } = string.Empty;
public string Status { get; set; } = "Active";
public int? MatchedPlotLineID { get; set; }
public string Source { get; set; } = WholeBookPlotSynthesisSources.Author;
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
}
public sealed class AuthorPlotSeedSaveRequest
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public IReadOnlyList<string> Descriptions { get; init; } = [];
}
public sealed class WholeBookPlotSynthesisRun
{
public int WholeBookPlotSynthesisRunID { get; set; }
public int ProjectID { get; set; }
public int BookID { get; set; }
public int UserID { get; set; }
public int? SourceStoryIntelligenceRunID { get; set; }
public string CharacterResolutionVersion { get; set; } = string.Empty;
public string Status { get; set; } = WholeBookPlotSynthesisStatuses.Queued;
public DateTime QueuedUtc { get; set; }
public DateTime? StartedUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
public DateTime? FailedUtc { get; set; }
public int AttemptCount { get; set; }
public string? Model { get; set; }
public string? OpenAIResponseID { get; set; }
public int? InputTokens { get; set; }
public int? OutputTokens { get; set; }
public int? CachedInputTokens { get; set; }
public int? TotalTokens { get; set; }
public long? DurationMs { get; set; }
public string? ErrorMessage { get; set; }
public string? ErrorDetail { get; set; }
public string? ResultJson { get; set; }
public DateTime UpdatedUtc { get; set; }
}
public sealed class WholeBookPlotSynthesisQueueRequest
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public int UserID { get; init; }
public int? SourceStoryIntelligenceRunID { get; init; }
public string CharacterResolutionVersion { get; init; } = string.Empty;
}
public sealed class WholeBookPlotSynthesisCompletionRequest
{
public int WholeBookPlotSynthesisRunID { get; init; }
public string Model { get; init; } = string.Empty;
public string? OpenAIResponseID { get; init; }
public int? InputTokens { get; init; }
public int? OutputTokens { get; init; }
public int? CachedInputTokens { get; init; }
public int? TotalTokens { get; init; }
public long? DurationMs { get; init; }
public string ResultJson { get; init; } = string.Empty;
}
public sealed class WholeBookPlotSynthesisContext
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public string BookTitle { get; init; } = string.Empty;
public IReadOnlyList<WholeBookPlotSynthesisChapter> Chapters { get; init; } = [];
public IReadOnlyList<WholeBookPlotSynthesisCharacter> Characters { get; init; } = [];
public IReadOnlyList<WholeBookPlotSynthesisExistingPlotLine> ExistingPlotLines { get; init; } = [];
public IReadOnlyList<AuthorPlotSeed> AuthorSeeds { get; init; } = [];
public WholeBookPlotSynthesisSourceAudit SourceAudit { get; init; } = new();
}
public sealed class WholeBookPlotSynthesisSourceAudit
{
public int SceneCount { get; init; }
public int SourceRowCount { get; init; }
public int MissingSourceCount { get; init; }
public int DuplicateSourceSceneCount { get; init; }
public bool CanReconstruct => SceneCount > 0 && SceneCount == SourceRowCount && MissingSourceCount == 0 && DuplicateSourceSceneCount == 0;
}
public sealed class WholeBookPlotSynthesisChapter
{
public int ChapterID { get; init; }
public decimal ChapterNumber { get; init; }
public string ChapterTitle { get; init; } = string.Empty;
public IReadOnlyList<WholeBookPlotSynthesisScene> Scenes { get; init; } = [];
}
public sealed class WholeBookPlotSynthesisScene
{
public int SceneID { get; init; }
public decimal SceneNumber { get; init; }
public string SceneTitle { get; init; } = string.Empty;
public string? StructuralSummary { get; init; }
public string? StructuralSetting { get; init; }
public string? PovCharacterName { get; init; }
public string SourceText { get; init; } = string.Empty;
}
public sealed class WholeBookPlotSynthesisCharacter
{
public int CharacterID { get; init; }
public string CanonicalName { get; init; } = string.Empty;
public IReadOnlyList<string> Aliases { get; init; } = [];
}
public sealed class WholeBookPlotSynthesisExistingPlotLine
{
public int PlotLineID { get; init; }
public string Name { get; init; } = string.Empty;
public string? Description { get; init; }
public string? Importance { get; init; }
public int? BookID { get; init; }
public IReadOnlyList<WholeBookPlotSynthesisExistingThread> Threads { get; init; } = [];
}
public sealed class WholeBookPlotSynthesisExistingThread
{
public int PlotThreadID { get; init; }
public string Title { get; init; } = string.Empty;
public string? Summary { get; init; }
public string? Status { get; init; }
}
public sealed class WholeBookPlotSynthesisResult
{
public WholeBookPlotSynthesisMetadata Metadata { get; init; } = new();
public IReadOnlyList<WholeBookPlotSynthesisPlotLineResult> PlotLines { get; init; } = [];
public static WholeBookPlotSynthesisResult FromJson(string json)
=> JsonSerializer.Deserialize<WholeBookPlotSynthesisResult>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
}
public sealed class WholeBookPlotSynthesisMetadata
{
public string BookStatus { get; init; } = string.Empty;
public string Summary { get; init; } = string.Empty;
}
public sealed class WholeBookPlotSynthesisPlotLineResult
{
public string Key { get; init; } = string.Empty;
public string ProposedTitle { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Significance { get; init; } = string.Empty;
public string BookStatus { get; init; } = string.Empty;
public int? ExistingPlotLineID { get; init; }
public decimal? ExistingPlotLineMatchConfidence { get; init; }
public string? ExistingPlotLineMatchReason { get; init; }
public int? AuthorPlotSeedID { get; init; }
public decimal? SeedMatchConfidence { get; init; }
public string? SeedMatchReason { get; init; }
public decimal? Confidence { get; init; }
public IReadOnlyList<WholeBookPlotSynthesisThreadResult> Threads { get; init; } = [];
}
public sealed class WholeBookPlotSynthesisThreadResult
{
public string Key { get; init; } = string.Empty;
public string ProposedTitle { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public int? ExistingPlotThreadID { get; init; }
public decimal? ExistingThreadMatchConfidence { get; init; }
public string? MatchReason { get; init; }
public decimal? Confidence { get; init; }
public IReadOnlyList<WholeBookPlotSynthesisEventResult> Events { get; init; } = [];
}
public sealed class WholeBookPlotSynthesisEventResult
{
public string Key { get; init; } = string.Empty;
public int SceneID { get; init; }
public string EventType { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Evidence { get; init; } = string.Empty;
public decimal? Confidence { get; init; }
}

View File

@ -245,6 +245,7 @@ public sealed class WordCompanionRuntimeCurrentSceneResponse
public int ChapterId { get; init; } public int ChapterId { get; init; }
public int SceneId { get; init; } public int SceneId { get; init; }
public string SceneUrl { get; init; } = string.Empty; public string SceneUrl { get; init; } = string.Empty;
public StoryPosition? Position => StoryPosition.FromRuntimeCurrentScene(this);
} }
public sealed class WordCompanionRuntimeCharacterAliasDto public sealed class WordCompanionRuntimeCharacterAliasDto

View File

@ -39,6 +39,7 @@ 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; }
} }

View File

@ -0,0 +1,76 @@
namespace PlotLine.Models;
public static class WritingWorkspaceRoles
{
public const string None = "None";
public const string Launcher = "Launcher";
public const string Context = "Context";
public const string Intent = "Intent";
public static string Normalise(string? role)
{
var value = (role ?? string.Empty).Trim();
return value.Equals(Launcher, StringComparison.OrdinalIgnoreCase) ? Launcher
: value.Equals(Context, StringComparison.OrdinalIgnoreCase) ? Context
: value.Equals(Intent, StringComparison.OrdinalIgnoreCase) ? Intent
: None;
}
}
public sealed class WritingWorkspaceScope
{
public int? ProjectID { get; init; }
public int? BookID { get; init; }
}
public sealed class WritingWindowRegistration
{
public string? BrowserWindowID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string? CurrentRoute { get; init; }
public string? WritingRole { get; init; }
public string? DisplayLabel { get; init; }
}
public sealed class WritingWindowHeartbeat
{
public string? BrowserWindowID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string? CurrentRoute { get; init; }
public string? WritingRole { get; init; }
public string? DisplayLabel { get; init; }
}
public sealed class WritingWindowPresence
{
public string BrowserWindowID { get; init; } = string.Empty;
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string CurrentRoute { get; init; } = string.Empty;
public string WritingRole { get; init; } = WritingWorkspaceRoles.None;
public string? DisplayLabel { get; init; }
public bool IsActive { get; init; }
public DateTime ConnectedUtc { get; init; }
public DateTime LastSeenUtc { get; init; }
}
public sealed class WritingWorkspaceStatus
{
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public bool WordCompanionConnected { get; init; }
public bool WordDocumentOpen { get; init; }
public int? WordProjectID { get; init; }
public int? WordBookID { get; init; }
public StoryPosition? CurrentStoryPosition { get; init; }
public IReadOnlyList<WritingWindowPresence> Windows { get; init; } = [];
public WritingWindowPresence? Launcher { get; init; }
public WritingWindowPresence? Context { get; init; }
public WritingWindowPresence? Intent { get; init; }
public bool ContextOpen => Context?.IsActive == true;
public bool IntentOpen => Intent?.IsActive == true;
}

View File

@ -25,6 +25,14 @@
<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>

View File

@ -122,6 +122,7 @@ 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>();
@ -144,13 +145,15 @@ 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<IWholeBookPlotIntelligenceRepository, WholeBookPlotIntelligenceRepository>();
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>();
@ -167,6 +170,7 @@ public class Program
builder.Services.AddScoped<IChapterService, ChapterService>(); builder.Services.AddScoped<IChapterService, ChapterService>();
builder.Services.AddScoped<ISceneService, SceneService>(); builder.Services.AddScoped<ISceneService, SceneService>();
builder.Services.AddScoped<IStoryStateService, StoryStateService>(); builder.Services.AddScoped<IStoryStateService, StoryStateService>();
builder.Services.AddScoped<IStoryContextService, StoryContextService>();
builder.Services.AddScoped<ITimelineService, TimelineService>(); builder.Services.AddScoped<ITimelineService, TimelineService>();
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>(); builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>(); builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();
@ -194,11 +198,13 @@ public class Program
builder.Services.AddScoped<IImportService, ImportService>(); builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>(); builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>(); builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>();
builder.Services.AddSingleton<IWritingWorkspacePresenceService, WritingWorkspacePresenceService>();
builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>(); builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>();
builder.Services.AddSingleton<IOnboardingStoryIntelligenceBatchStore, OnboardingStoryIntelligenceBatchStore>(); builder.Services.AddSingleton<IOnboardingStoryIntelligenceBatchStore, OnboardingStoryIntelligenceBatchStore>();
builder.Services.AddHostedService<WordCompanionPresenceMonitor>(); builder.Services.AddHostedService<WordCompanionPresenceMonitor>();
builder.Services.AddScoped<IOnboardingService, OnboardingService>(); builder.Services.AddScoped<IOnboardingService, OnboardingService>();
builder.Services.AddScoped<IOnboardingStoryIntelligenceService, OnboardingStoryIntelligenceService>(); builder.Services.AddScoped<IOnboardingStoryIntelligenceService, OnboardingStoryIntelligenceService>();
builder.Services.AddScoped<ICharacterSexHintAiService, CharacterSexHintAiService>();
builder.Services.AddScoped<IStoryIntelligenceService, StoryIntelligenceService>(); builder.Services.AddScoped<IStoryIntelligenceService, StoryIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligenceProvider, StubStoryIntelligenceProvider>(); builder.Services.AddScoped<IStoryIntelligenceProvider, StubStoryIntelligenceProvider>();
builder.Services.AddSingleton<IStoryPromptRepository, StoryPromptRepository>(); builder.Services.AddSingleton<IStoryPromptRepository, StoryPromptRepository>();
@ -212,6 +218,9 @@ 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>();
@ -221,11 +230,18 @@ 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<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>(); builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
builder.Services.AddScoped<IWholeBookPlotIntelligenceService, WholeBookPlotIntelligenceService>();
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<IStoryIntelligenceMaterialisationService, StoryIntelligenceMaterialisationService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<IStoryIntelligenceGlobalProgressService, StoryIntelligenceGlobalProgressService>();
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>();
@ -233,7 +249,6 @@ 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>();
@ -250,7 +265,6 @@ public class Program
builder.Services.AddHostedService<EmailQueueWorker>(); builder.Services.AddHostedService<EmailQueueWorker>();
if (builder.Configuration.GetValue("StoryIntelligence:WorkersEnabled", true)) if (builder.Configuration.GetValue("StoryIntelligence:WorkersEnabled", true))
{ {
builder.Services.AddHostedService<StoryIntelligenceWorker>();
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>(); builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
} }
if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true)) if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true))

View File

@ -50,6 +50,7 @@ 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

View File

@ -180,6 +180,38 @@ 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))

View File

@ -58,7 +58,13 @@ public sealed class ChapterStoryIntelligenceDryRunService(
cancellationToken, cancellationToken,
clientStatus.ChapterStructureModel); clientStatus.ChapterStructureModel);
var chapterJson = ExtractOutputText(chapterResult.RawResponseText); var chapterJson = ExtractOutputText(chapterResult.RawResponseText);
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions); var chapterParse = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(chapterJson, JsonOptions);
if (!chapterParse.Success)
{
throw new JsonException(chapterParse.ErrorMessage ?? "Chapter Structure JSON could not be parsed.");
}
var parsedChapter = chapterParse.Parsed;
var 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);

View File

@ -13,40 +13,51 @@ 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 phantomParagraph = paragraphCount + 1;
var trimmedTrailingEmptyCount = 0;
while (boundaries.Count >= 2)
{
var finalIndex = boundaries.Count - 1; var finalIndex = boundaries.Count - 1;
var final = boundaries[finalIndex]; var final = boundaries[finalIndex];
var previous = boundaries[finalIndex - 1]; var previous = boundaries[finalIndex - 1];
var phantomParagraph = paragraphCount + 1; if (!IsDiscardableTrailingBoundary(final, previous, paragraphCount, phantomParagraph))
if (IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph)
|| IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph))
{ {
var normalisedBoundaries = boundaries break;
}
boundaries = boundaries
.Take(finalIndex) .Take(finalIndex)
.Select((boundary, index) => CopyBoundary(boundary, index + 1)) .Select((boundary, index) => CopyBoundary(boundary, index + 1))
.ToList(); .ToList();
trimmedTrailingEmptyCount++;
}
var normalised = new ChapterStructureModel if (trimmedTrailingEmptyCount > 0)
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = "sceneBoundaries",
Message = $"Discarded {trimmedTrailingEmptyCount:N0} empty trailing scene boundaries after paragraph {paragraphCount:N0}.",
SuggestedFix = $"Do not return scene boundaries after the final supplied paragraph {paragraphCount:N0} has already been covered."
});
}
var boundaryModel = ReferenceEquals(boundaries, chapterStructure.SceneBoundaries)
? chapterStructure
: new ChapterStructureModel
{ {
SchemaVersion = chapterStructure.SchemaVersion, SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary, ChapterSummary = chapterStructure.ChapterSummary,
SceneBoundaries = normalisedBoundaries, SceneBoundaries = boundaries.ToList(),
ExtensionData = chapterStructure.ExtensionData ExtensionData = chapterStructure.ExtensionData
}; };
var continuity = RepairContinuity(boundaryModel, boundaries, paragraphCount);
var issue = new ValidationIssue issues.AddRange(continuity.Issues);
{ return new ChapterStructureBoundaryNormalisationResult(continuity.ChapterStructure, issues);
Severity = "Warning",
Path = $"sceneBoundaries[{finalIndex}]",
Message = BuildNormalisationMessage(final, finalIndex, paragraphCount, phantomParagraph),
SuggestedFix = "Do not return a final scene boundary unless it references an actual supplied paragraph."
};
return new ChapterStructureBoundaryNormalisationResult(normalised, [issue]);
}
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
} }
public static void AddIssuesTo(ValidationResult validation, ChapterStructureBoundaryNormalisationResult normalisation) public static void AddIssuesTo(ValidationResult validation, ChapterStructureBoundaryNormalisationResult normalisation)
@ -74,6 +85,8 @@ 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
{ {
@ -92,11 +105,185 @@ 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 discardedTrailingEmptyCount = 0;
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 (expectedStart > paragraphCount && ContainsNoRemainingParagraphs(start, end, paragraphCount))
{
discardedTrailingEmptyCount++;
changed = true;
continue;
}
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 (discardedTrailingEmptyCount > 0)
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = "sceneBoundaries",
Message = $"Discarded {discardedTrailingEmptyCount:N0} empty trailing scene boundaries after paragraph {paragraphCount:N0}.",
SuggestedFix = $"Do not return scene boundaries after the final supplied paragraph {paragraphCount:N0} has already been covered."
});
for (var index = 0; index < repaired.Count; index++)
{
repaired[index] = CopyBoundary(repaired[index], index + 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 IsDiscardableTrailingBoundary(
ChapterSceneBoundary final,
ChapterSceneBoundary previous,
int paragraphCount,
int phantomParagraph)
=> IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph)
|| IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph)
|| IsTrailingTerminalOffByOne(final, previous, paragraphCount, phantomParagraph);
private static bool IsTrailingEmptyBoundary( private static bool IsTrailingEmptyBoundary(
ChapterSceneBoundary final, ChapterSceneBoundary final,
ChapterSceneBoundary previous, ChapterSceneBoundary previous,
@ -127,6 +314,15 @@ 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)
@ -141,22 +337,8 @@ public static class ChapterStructureBoundaryNormaliser
|| reason.Contains("coverage", StringComparison.OrdinalIgnoreCase); || reason.Contains("coverage", StringComparison.OrdinalIgnoreCase);
} }
private static string BuildNormalisationMessage( private static bool ContainsNoRemainingParagraphs(int start, int end, int paragraphCount)
ChapterSceneBoundary final, => start > paragraphCount || end <= paragraphCount;
int finalIndex,
int paragraphCount,
int phantomParagraph)
{
var start = final.StartParagraph ?? 0;
var end = final.EndParagraph ?? 0;
if (start == phantomParagraph && end == phantomParagraph)
{
return $"Discarded trailing empty scene boundary {phantomParagraph}-{phantomParagraph} because submitted chapter has {paragraphCount} paragraphs and previous scene already ended at {paragraphCount}.";
}
var sceneNumber = final.SceneNumber ?? finalIndex + 1;
return $"Discarded trailing invalid placeholder scene {sceneNumber} with range {start}-{end} because previous scene already ended at paragraph {paragraphCount}.";
}
} }
public sealed record ChapterStructureBoundaryNormalisationResult( public sealed record ChapterStructureBoundaryNormalisationResult(

View File

@ -119,6 +119,15 @@ 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);
} }

View File

@ -0,0 +1,152 @@
using System.Text.Json;
using PlotLine.Models;
namespace PlotLine.Services;
public interface ICharacterSexHintAiService
{
Task<IReadOnlyDictionary<string, string>> SuggestAsync(
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates,
CancellationToken cancellationToken);
}
public sealed class CharacterSexHintAiService(
IStoryIntelligenceClient client,
ILogger<CharacterSexHintAiService> logger) : ICharacterSexHintAiService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyDictionary<string, string>> SuggestAsync(
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates,
CancellationToken cancellationToken)
{
var promptCandidates = candidates
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.TemporaryCharacterKey) && !string.IsNullOrWhiteSpace(candidate.Name))
.Select(candidate => new CharacterSexHintPromptCandidate(candidate.TemporaryCharacterKey, candidate.Name.Trim()))
.DistinctBy(candidate => candidate.Key, StringComparer.Ordinal)
.ToList();
if (promptCandidates.Count == 0)
{
return new Dictionary<string, string>();
}
try
{
var result = await client.ExecutePromptAsync(
BuildPrompt(promptCandidates),
"Character-Sex-Hint-V1",
cancellationToken,
maxOutputTokensOverride: Math.Max(600, promptCandidates.Count * 20));
var outputText = ExtractOutputText(result.RawResponseText);
return ParseSuggestions(outputText, promptCandidates);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "AI character sex hinting failed; falling back to deterministic title/name hints.");
return new Dictionary<string, string>();
}
}
private static string BuildPrompt(IReadOnlyList<CharacterSexHintPromptCandidate> candidates)
{
var payload = JsonSerializer.Serialize(candidates.Select(candidate => new
{
key = candidate.Key,
name = candidate.Name
}), JsonOptions);
return $$"""
You are helping prepare a fiction manuscript import review.
For each candidate character name, infer the most probable sex presentation from the name/title alone.
Return only valid JSON in this exact shape:
{
"characters": [
{ "key": "same key", "sexHint": "Female|Male|NonBinaryOrOther|Unknown" }
]
}
Rules:
- Use Female or Male for common names and explicit titles such as Miss, Mrs, Mr, Sir, Lady.
- Use Unknown when the name is genuinely ambiguous, invented, a surname-only reference, or not enough information is present.
- Use NonBinaryOrOther only when the supplied name/title explicitly indicates that.
- Do not add explanations.
Candidates:
{{payload}}
""";
}
private static string ExtractOutputText(string rawResponseText)
{
var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(rawResponseText, JsonOptions)
?? throw new JsonException("OpenAI response envelope was empty.");
return string.Concat(response.Output?
.SelectMany(item => item.Content ?? [])
.Where(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase))
.Select(content => content.Text)
.Where(text => !string.IsNullOrWhiteSpace(text))
?? []);
}
private static IReadOnlyDictionary<string, string> ParseSuggestions(
string outputText,
IReadOnlyList<CharacterSexHintPromptCandidate> candidates)
{
if (string.IsNullOrWhiteSpace(outputText))
{
return new Dictionary<string, string>();
}
var candidateKeys = candidates.Select(candidate => candidate.Key).ToHashSet(StringComparer.Ordinal);
using var document = JsonDocument.Parse(StripMarkdownFence(outputText));
if (!document.RootElement.TryGetProperty("characters", out var characters)
|| characters.ValueKind != JsonValueKind.Array)
{
return new Dictionary<string, string>();
}
var suggestions = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var item in characters.EnumerateArray())
{
if (!item.TryGetProperty("key", out var keyProperty)
|| !item.TryGetProperty("sexHint", out var sexHintProperty))
{
continue;
}
var key = keyProperty.GetString();
if (string.IsNullOrWhiteSpace(key) || !candidateKeys.Contains(key))
{
continue;
}
var hint = CharacterSexHintService.Normalise(sexHintProperty.GetString());
suggestions[key] = hint;
}
return suggestions;
}
private static string StripMarkdownFence(string value)
{
var trimmed = value.Trim();
if (!trimmed.StartsWith("```", StringComparison.Ordinal))
{
return trimmed;
}
var firstLineEnd = trimmed.IndexOf('\n');
if (firstLineEnd < 0)
{
return trimmed;
}
var withoutOpeningFence = trimmed[(firstLineEnd + 1)..].Trim();
return withoutOpeningFence.EndsWith("```", StringComparison.Ordinal)
? withoutOpeningFence[..^3].Trim()
: withoutOpeningFence;
}
private sealed record CharacterSexHintPromptCandidate(string Key, string Name);
}

View File

@ -9,21 +9,30 @@ public static class CharacterSexHintService
private static readonly HashSet<string> FemaleNames = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> FemaleNames = new(StringComparer.OrdinalIgnoreCase)
{ {
"abby", "abigail", "alice", "alison", "allison", "amanda", "amelia", "amy", "angela", "anna", "anne", "abby", "abigail", "ada", "adele", "alice", "alison", "allison", "amanda", "amelia", "amy",
"beth", "bethany", "carol", "caroline", "catherine", "charlotte", "chloe", "cindy", "claire", "daisy", "angela", "anna", "anne", "annie", "barbara", "becky", "belinda", "beth", "bethany", "brenda",
"diana", "emily", "emma", "eva", "eve", "grace", "hannah", "helen", "isabel", "isabella", "jane", "carol", "caroline", "catherine", "charlotte", "chloe", "chrissy", "christine", "cindy", "claire", "clara",
"jessica", "julia", "julie", "kate", "katie", "laura", "lily", "lucy", "maggie", "margaret", "mary", "daisy", "debbie", "debby", "deborah", "diana", "diane", "donna", "dorothy", "elaine", "elizabeth",
"molly", "nicola", "olivia", "rachel", "rebecca", "rose", "sarah", "sophie", "susan", "victoria", "ella", "ellie", "emily", "emma", "eva", "eve", "fiona", "gemma", "georgina", "grace", "hannah",
"zoe" "harriet", "heather", "helen", "isabel", "isabella", "jacqueline", "jane", "janet", "janice",
"jean", "jennifer", "jenny", "jessica", "joanne", "judy", "julia", "julie", "karen", "kate", "katie",
"katherine", "kathryn", "kelly", "laura", "lesley", "linda", "lisa", "lily", "louise", "lucy",
"maggie", "margaret", "maria", "marie", "marion", "mary", "michelle", "molly", "nicola", "olivia",
"pamela", "patricia", "paula", "rachel", "rebecca", "rose", "sally", "samantha", "sandra", "sarah",
"shirley", "sophie", "stephanie", "susan", "suzanne", "tracey", "tracy", "valerie", "victoria",
"wendy", "zoe"
}; };
private static readonly HashSet<string> MaleNames = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> MaleNames = new(StringComparer.OrdinalIgnoreCase)
{ {
"adam", "alan", "alexander", "andrew", "anthony", "arthur", "ben", "benjamin", "brian", "charles", "adam", "alan", "albert", "alexander", "andrew", "anthony", "arthur", "barry", "ben", "benjamin",
"chris", "christopher", "daniel", "david", "edward", "george", "harry", "henry", "ian", "jack", "bernard", "brian", "bruce", "charles", "chris", "christopher", "colin", "craig", "daniel", "darren",
"james", "john", "jonathan", "joseph", "kevin", "mark", "matthew", "michael", "nick", "nicolas", "david", "dennis", "douglas", "edward", "eric", "frank", "gary", "geoff", "geoffrey", "george",
"nicholas", "oliver", "paul", "peter", "philip", "richard", "robert", "samuel", "simon", "stephen", "graham", "grant", "greg", "harry", "henry", "howard", "ian", "jack", "james", "john", "jonathan", "joseph",
"steven", "thomas", "tom", "william" "keith", "kenneth", "kevin", "liam", "martin", "mark", "matthew", "michael", "neil", "nick", "nicolas",
"nicholas", "nigel", "oliver", "paul", "peter", "phil", "philip", "raymond", "richard", "robert", "roger",
"ronald", "samuel", "simon", "stephen", "steven", "stuart", "terence", "thomas", "tim", "timothy",
"tom", "tony", "william"
}; };
private static readonly HashSet<string> AmbiguousNames = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> AmbiguousNames = new(StringComparer.OrdinalIgnoreCase)
@ -33,9 +42,26 @@ public static class CharacterSexHintService
"sam", "sandy", "shannon", "taylor", "terry" "sam", "sandy", "shannon", "taylor", "terry"
}; };
private static readonly Dictionary<string, string> TitleHints = new(StringComparer.OrdinalIgnoreCase)
{
["mr"] = Male,
["mister"] = Male,
["sir"] = Male,
["mrs"] = Female,
["miss"] = Female,
["ms"] = Female,
["madam"] = Female,
["lady"] = Female
};
public static string Suggest(string? name) public static string Suggest(string? name)
{ {
var token = FirstNameToken(name); var token = FirstNameToken(name, out var titleHint);
if (!string.IsNullOrWhiteSpace(titleHint))
{
return titleHint;
}
if (string.IsNullOrWhiteSpace(token) || AmbiguousNames.Contains(token)) if (string.IsNullOrWhiteSpace(token) || AmbiguousNames.Contains(token))
{ {
return Unknown; return Unknown;
@ -59,15 +85,35 @@ public static class CharacterSexHintService
_ => Suggest(fallbackName) _ => Suggest(fallbackName)
}; };
private static string FirstNameToken(string? name) private static string FirstNameToken(string? name, out string? titleHint)
{ {
titleHint = null;
var clean = name?.Trim(); var clean = name?.Trim();
if (string.IsNullOrWhiteSpace(clean)) if (string.IsNullOrWhiteSpace(clean))
{ {
return string.Empty; return string.Empty;
} }
var first = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; var tokens = clean
return first.Trim('.', ',', ';', ':', '\'', '"').ToLowerInvariant(); .Split([' ', '-', '_'], StringSplitOptions.RemoveEmptyEntries)
.Select(CleanToken)
.Where(token => !string.IsNullOrWhiteSpace(token))
.ToList();
if (tokens.Count == 0)
{
return string.Empty;
}
if (TitleHints.TryGetValue(tokens[0], out titleHint))
{
return tokens.Skip(1).FirstOrDefault() ?? string.Empty;
}
return tokens[0];
}
private static string CleanToken(string token)
{
return token.Trim('.', ',', ';', ':', '\'', '"', '(', ')', '[', ']').ToLowerInvariant();
} }
} }

View File

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

View File

@ -3,6 +3,7 @@ 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;
@ -113,6 +114,7 @@ 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);
} }
@ -121,7 +123,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); Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false);
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);
@ -146,6 +148,23 @@ public interface IStoryStateService
Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId); Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId);
} }
public interface IStoryContextService
{
Task<StoryContextViewModel?> GetAsync(StoryPosition position);
Task<StoryContextViewModel?> GetForSceneAsync(int sceneId, int? projectId = null, int? bookId = null);
}
public sealed class StoryContextViewModel
{
public StoryPosition Position { get; init; } = new(0);
public Project? Project { get; init; }
public Book? Book { get; init; }
public Chapter? Chapter { get; init; }
public SceneEditViewModel? Scene { get; init; }
public StoryStateViewModel StoryState { get; init; } = new();
public string? CanonicalUrl => Position.SceneEditUrl;
}
public interface IPlotService public interface IPlotService
{ {
Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId); Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId);
@ -243,8 +262,10 @@ 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);
@ -292,7 +313,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 AddMissingDefaultsAsync(int projectId); Task<int> AddMissingDefaultsAsync(int projectId);
Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId); Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId);
} }
@ -313,6 +334,7 @@ public interface IStoryBibleService
public interface IRelationshipMapService public interface IRelationshipMapService
{ {
Task<RelationshipMapViewModel?> GetAsync(StoryPosition position, int? focusCharacterId, bool showFullNetwork);
Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork); Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork);
} }
@ -899,6 +921,13 @@ 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.",
@ -1135,6 +1164,7 @@ 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,
@ -1156,6 +1186,11 @@ 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,
@ -1164,9 +1199,8 @@ 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 = currentUser.UserId is int pipelineUserId StoryIntelligencePipeline = pipeline,
? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId) StoryIntelligenceAction = BuildStoryIntelligenceAction(pipeline, hasCanonicalSceneSources)
: null
}; };
} }
@ -1186,7 +1220,9 @@ 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)
}; };
} }
@ -1210,6 +1246,8 @@ 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,
@ -1217,7 +1255,9 @@ 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)
}; };
} }
@ -1225,6 +1265,8 @@ 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);
@ -1258,6 +1300,8 @@ 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,
@ -1283,6 +1327,31 @@ 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
@ -1295,6 +1364,46 @@ 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,
CanContinue = false,
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."
};
}
var isInProgress = string.Equals(pipeline.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase);
var needsCharacterReview = pipeline.CharacterReviewCompletedUtc is null
&& (string.Equals(pipeline.CurrentReviewStage, StoryIntelligencePipelineStages.CharacterReview, StringComparison.OrdinalIgnoreCase)
|| string.Equals(pipeline.CurrentStage, StoryIntelligencePipelineStages.CharacterReview, StringComparison.OrdinalIgnoreCase));
return new StoryIntelligenceBookActionViewModel
{
HasCanonicalSceneSources = hasCanonicalSceneSources,
CanStart = false,
CanContinue = !isInProgress,
StatusLabel = pipeline.IsComplete
? "Review complete"
: isInProgress
? "Analysis in progress"
: needsCharacterReview ? "Character review required" : "Needs review",
ActionLabel = isInProgress ? "Analysis running" : "Open Review Centre",
Description = pipeline.IsComplete
? "Story Intelligence review is saved for this book."
: isInProgress
? "Story Intelligence is analysing this book in the background."
: "Story Intelligence has saved review categories 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);
@ -1317,6 +1426,8 @@ 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)
@ -1335,6 +1446,12 @@ 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);
@ -1357,9 +1474,12 @@ 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)
}; };
} }
@ -1382,7 +1502,8 @@ 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)
}; };
} }
@ -1402,12 +1523,18 @@ 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,
@ -1415,7 +1542,8 @@ 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)
}; };
} }
@ -1427,12 +1555,14 @@ 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
@ -1442,9 +1572,27 @@ 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);
@ -1465,6 +1613,39 @@ 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(
@ -1484,6 +1665,7 @@ 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)
@ -1525,6 +1707,7 @@ 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,
@ -1548,6 +1731,7 @@ 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
{ {
@ -1596,6 +1780,7 @@ 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
@ -1617,8 +1802,8 @@ public sealed class SceneService(
return sceneId; return sceneId;
} }
public Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId) => public Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false) =>
scenes.UpdatePovCharacterAsync(sceneId, povCharacterId); scenes.UpdatePovCharacterAsync(sceneId, povCharacterId, source, confidence, evidence, suggestedName, overwriteAuthor);
public async Task ArchiveAsync(int sceneId) public async Task ArchiveAsync(int sceneId)
{ {
@ -2017,12 +2202,69 @@ public sealed class SceneService(
} }
} }
public sealed class StoryContextService(ISceneService scenes) : IStoryContextService
{
public async Task<StoryContextViewModel?> GetAsync(StoryPosition position)
{
if (!position.IsValid)
{
return null;
}
if (position.SceneId.HasValue)
{
return await GetForSceneAsync(position.SceneId.Value, position.ProjectId, position.BookId);
}
return new StoryContextViewModel
{
Position = position
};
}
public async Task<StoryContextViewModel?> GetForSceneAsync(int sceneId, int? projectId = null, int? bookId = null)
{
if (sceneId <= 0)
{
return null;
}
var scene = await scenes.GetEditAsync(sceneId);
var position = StoryPosition.FromScene(scene);
if (position is null)
{
return null;
}
if (projectId.HasValue && projectId.Value != position.ProjectId)
{
return null;
}
if (bookId.HasValue && position.BookId.HasValue && bookId.Value != position.BookId.Value)
{
return null;
}
return new StoryContextViewModel
{
Position = position,
Project = scene!.Project,
Book = scene.Book,
Chapter = scene.Chapter,
Scene = scene,
StoryState = scene.StoryState
};
}
}
public sealed class TimelineService( public sealed class TimelineService(
ITimelineRepository timelineRepository, ITimelineRepository timelineRepository,
ITimelinePresetRepository presets, ITimelinePresetRepository presets,
ITimelineSettingsRepository timelineSettings, ITimelineSettingsRepository timelineSettings,
IProjectRepository projects, IProjectRepository projects,
IBookRepository books, IBookRepository books,
IStoryContextService storyContext,
ISceneService scenes, ISceneService scenes,
IAssetRepository assets, IAssetRepository assets,
ICharacterRepository characters, ICharacterRepository characters,
@ -2141,9 +2383,10 @@ public sealed class TimelineService(
var characterLanes = settings.ShowCharacterAppearances var characterLanes = settings.ShowCharacterAppearances
? await TimeTimelineLoadAsync("TimelineService.BuildCharacterLanes", () => Task.FromResult(BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)), projectId, bookId) ? await TimeTimelineLoadAsync("TimelineService.BuildCharacterLanes", () => Task.FromResult(BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)), projectId, bookId)
: []; : [];
var selectedScene = selectedSceneId.HasValue var selectedContext = selectedSceneId.HasValue
? await TimeTimelineLoadAsync("SceneService.GetEditAsync", () => scenes.GetEditAsync(selectedSceneId.Value), projectId, bookId) ? await TimeTimelineLoadAsync("StoryContextService.GetForSceneAsync", () => storyContext.GetForSceneAsync(selectedSceneId.Value, projectId, bookId), projectId, bookId)
: null; : null;
var selectedScene = selectedContext?.Scene;
if (selectedScene is not null) if (selectedScene is not null)
{ {
selectedScene.ReturnProjectID = projectId; selectedScene.ReturnProjectID = projectId;
@ -5171,7 +5414,11 @@ 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 Task AddMissingDefaultsAsync(int projectId) => metricTypes.AddMissingDefaultsAsync(projectId); public async Task<int> AddMissingDefaultsAsync(int 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);
} }
@ -5340,6 +5587,13 @@ public sealed class StoryBibleService(IProjectRepository projects, IStoryBibleRe
public sealed class RelationshipMapService(IProjectRepository projects, ICharacterRepository characters) : IRelationshipMapService public sealed class RelationshipMapService(IProjectRepository projects, ICharacterRepository characters) : IRelationshipMapService
{ {
public Task<RelationshipMapViewModel?> GetAsync(StoryPosition position, int? focusCharacterId, bool showFullNetwork)
{
return !position.IsValid
? Task.FromResult<RelationshipMapViewModel?>(null)
: GetAsync(position.ProjectId, position.BookId, position.ChapterId, position.SceneId, focusCharacterId, showFullNetwork);
}
public async Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork) public async Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork)
{ {
if (projectId <= 0) if (projectId <= 0)
@ -9029,6 +9283,35 @@ 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);
@ -9104,6 +9387,26 @@ 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);

View File

@ -16,7 +16,7 @@ public interface IIllustrationPromptBuilder
public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
{ {
public const string CurrentTemplateVersion = "21J.3"; public const string CurrentTemplateVersion = "22C.1";
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 high-quality editorial illustration for PlotDirector's reusable Story Intelligence illustration library. Create a realistic cinematic still 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:
- representative visual identifier only, not a canonical depiction of any manuscript - realistic grounded visual identifier only, not a canonical depiction of any manuscript
- modern cinematic digital illustration with premium narrative game concept art quality - naturalistic cinematic still, close to believable production photography while remaining safe for a reusable UI library
- subdued atmospheric background treatment with contemporary polished rendering, realistic anatomy and believable proportions - subtle 1980s UK period detail where requested, grounded wardrobe and hair, not costume-party exaggeration
- neutral background treatment suitable for PlotDirector UI, with believable anatomy and proportions
- 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
- polished promotional artwork for a premium story-driven adventure game, not a literal photograph - subtle depth of field, clean realistic rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface
- 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, or photoreal identity claim - no anime, cartoon, Pixar-like, caricature, celebrity likeness, oil painting, brushwork, painterly texture, fantasy gloss, fashion glamour portrait, or over-stylised concept art
- 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; 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.Character => "Square image; realistic head-and-shoulders or upper body; one person only; face-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from a subtle grounded story background; suitable for circular cropping; avoid bright flat studio backgrounds, plain colour blocks, dark collars vanishing into black backgrounds, and fashion headshot styling.",
IllustrationLibraryCategories.Location => "One clear focal environment; 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.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.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,6 +333,9 @@ 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);
@ -347,6 +350,9 @@ 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
{ {
@ -374,21 +380,35 @@ 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(new httpRequest.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
{
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 body = await response.Content.ReadAsStringAsync(cancellationToken); var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var error = OpenAIImageError.FromResponseBody(body); var error = OpenAIImageError.FromResponseBody(responseBody);
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,
@ -398,7 +418,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(body); using var document = JsonDocument.Parse(responseBody);
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))
@ -406,7 +426,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); return new(true, Convert.FromBase64String(b64), "image/png", ProviderName, EffectiveModel, requestId, quality, size, outputFormat);
} }
private static string TrimForStorage(string value) private static string TrimForStorage(string value)
@ -560,6 +580,16 @@ 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)
{ {
@ -619,14 +649,12 @@ 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, IIllustrationImageProvider provider) : IIllustrationLibraryService
IUploadStorageService uploadStorage) : IIllustrationLibraryService
{ {
private const int MaxGenerationAttempts = 3; private const int MaxGenerationAttempts = 3;
@ -923,113 +951,4 @@ 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)));
} }

View File

@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.Json;
using PlotLine.Models; using PlotLine.Models;
namespace PlotLine.Services; namespace PlotLine.Services;
@ -8,20 +9,25 @@ public interface IManuscriptScanPreviewStore
Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId); Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId);
Task ClearAsync(int userId, int onboardingId); Task ClearAsync(int userId, int onboardingId);
Task<ManuscriptScanState> ProgressAsync(int userId, ManuscriptScanProgress progress); Task<ManuscriptScanState> ProgressAsync(int userId, ManuscriptScanProgress progress);
Task<ManuscriptScanState> AddChunkAsync(int userId, ManuscriptScanChunk chunk);
Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview); Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview);
Task<ManuscriptScanState> FailAsync(int userId, int onboardingId, string message); Task<ManuscriptScanState> FailAsync(int userId, int onboardingId, string message);
Task<IReadOnlyList<ManuscriptScanState>> FailRunningForUserAsync(int userId, string message); Task<IReadOnlyList<ManuscriptScanState>> FailRunningForUserAsync(int userId, string message);
Task<bool> HasRecentRunningScanForUserAsync(int userId, TimeSpan maxAge);
Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId); Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId);
Task<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId); Task<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId);
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);
} }
public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore public sealed class ManuscriptScanPreviewStore(
IServiceScopeFactory scopeFactory,
ILogger<ManuscriptScanPreviewStore> logger) : IManuscriptScanPreviewStore
{ {
private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new(); private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new();
private readonly ConcurrentDictionary<Guid, ManuscriptScanPreview> previews = new(); private readonly ConcurrentDictionary<Guid, ManuscriptScanPreview> previews = new();
@ -33,6 +39,7 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
{ {
var session = new ScanSession var session = new ScanSession
{ {
ScanID = Guid.NewGuid(),
UserID = userId, UserID = userId,
OnboardingID = onboardingId, OnboardingID = onboardingId,
ProjectID = projectId, ProjectID = projectId,
@ -62,6 +69,11 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
Status = ManuscriptScanStatuses.Running Status = ManuscriptScanStatuses.Running
}); });
if (string.Equals(session.Status, ManuscriptScanStatuses.Complete, StringComparison.Ordinal))
{
return Task.FromResult(ToState(session));
}
session.Status = ManuscriptScanStatuses.Running; session.Status = ManuscriptScanStatuses.Running;
session.Message = Clean(progress.Message, "Scanning manuscript"); session.Message = Clean(progress.Message, "Scanning manuscript");
session.PercentComplete = progress.PercentComplete; session.PercentComplete = progress.PercentComplete;
@ -73,6 +85,73 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
return Task.FromResult(ToState(session)); return Task.FromResult(ToState(session));
} }
public Task<ManuscriptScanState> AddChunkAsync(int userId, ManuscriptScanChunk chunk)
{
if (chunk.ScanID == Guid.Empty
|| chunk.OnboardingID <= 0
|| chunk.ChunkIndex < 0
|| chunk.TotalChunks <= 0
|| chunk.ChunkIndex >= chunk.TotalChunks
|| chunk.TotalLength <= 0
|| string.IsNullOrEmpty(chunk.Payload))
{
throw new InvalidOperationException("The scan result chunk was invalid.");
}
if (!sessions.TryGetValue((userId, chunk.OnboardingID), out var session)
|| !string.Equals(session.Status, ManuscriptScanStatuses.Running, StringComparison.Ordinal)
|| session.ScanID != chunk.ScanID)
{
throw new InvalidOperationException("The scan result no longer matches an active scan.");
}
if (session.ExpectedChunkCount.HasValue && session.ExpectedChunkCount.Value != chunk.TotalChunks
|| session.ExpectedPayloadLength.HasValue && session.ExpectedPayloadLength.Value != chunk.TotalLength)
{
throw new InvalidOperationException("The scan result chunk metadata changed during upload.");
}
session.ExpectedChunkCount = chunk.TotalChunks;
session.ExpectedPayloadLength = chunk.TotalLength;
if (session.Chunks.TryGetValue(chunk.ChunkIndex, out var existingPayload))
{
if (!string.Equals(existingPayload, chunk.Payload, StringComparison.Ordinal))
{
throw new InvalidOperationException("A duplicate scan result chunk did not match the original payload.");
}
}
else
{
session.Chunks[chunk.ChunkIndex] = chunk.Payload;
}
session.Message = $"Receiving manuscript scan result ({session.Chunks.Count:N0}/{chunk.TotalChunks:N0})";
session.PercentComplete = Math.Min(99, 95 + (int)Math.Floor(session.Chunks.Count * 4m / chunk.TotalChunks));
session.UpdatedUtc = DateTime.UtcNow;
if (session.Chunks.Count < chunk.TotalChunks)
{
return Task.FromResult(ToState(session));
}
for (var index = 0; index < chunk.TotalChunks; index++)
{
if (!session.Chunks.ContainsKey(index))
{
throw new InvalidOperationException("The scan result is missing one or more chunks.");
}
}
var payload = string.Concat(Enumerable.Range(0, chunk.TotalChunks).Select(index => session.Chunks[index]));
if (payload.Length != chunk.TotalLength)
{
throw new InvalidOperationException("The assembled scan result length did not match the expected length.");
}
var preview = JsonSerializer.Deserialize<ManuscriptScanPreview>(payload, JsonOptions)
?? throw new InvalidOperationException("The assembled scan result could not be read.");
return CompleteAsync(userId, preview);
}
public Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview) public Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview)
{ {
var stored = preview.WithUser(userId); var stored = preview.WithUser(userId);
@ -90,11 +169,12 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
session.Message = "Scan complete"; session.Message = "Scan complete";
session.PercentComplete = 100; session.PercentComplete = 100;
session.ChapterCount = stored.ChapterCount; session.ChapterCount = stored.ChapterCount;
session.SceneCount = stored.SceneCount; session.SceneCount = 0;
session.CharacterCandidateCount = stored.CharacterCandidateCount; session.CharacterCandidateCount = 0;
session.TotalWordCount = stored.TotalWordCount; session.TotalWordCount = stored.TotalWordCount;
session.PreviewID = stored.PreviewID; session.PreviewID = stored.PreviewID;
session.UpdatedUtc = stored.CreatedUtc; session.UpdatedUtc = stored.CreatedUtc;
session.Chunks.Clear();
return Task.FromResult(ToState(session)); return Task.FromResult(ToState(session));
} }
@ -108,6 +188,7 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
session.Status = ManuscriptScanStatuses.Failed; session.Status = ManuscriptScanStatuses.Failed;
session.Message = Clean(message, "The scan could not finish. Try reconnecting the Word Companion."); session.Message = Clean(message, "The scan could not finish. Try reconnecting the Word Companion.");
session.UpdatedUtc = DateTime.UtcNow; session.UpdatedUtc = DateTime.UtcNow;
session.Chunks.Clear();
return Task.FromResult(ToState(session)); return Task.FromResult(ToState(session));
} }
@ -124,12 +205,23 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
pair.Value.Status = ManuscriptScanStatuses.Failed; pair.Value.Status = ManuscriptScanStatuses.Failed;
pair.Value.Message = Clean(message, "The Word Companion disconnected before the scan finished. Reopen Word and try again."); pair.Value.Message = Clean(message, "The Word Companion disconnected before the scan finished. Reopen Word and try again.");
pair.Value.UpdatedUtc = DateTime.UtcNow; pair.Value.UpdatedUtc = DateTime.UtcNow;
pair.Value.Chunks.Clear();
failed.Add(ToState(pair.Value)); failed.Add(ToState(pair.Value));
} }
return Task.FromResult<IReadOnlyList<ManuscriptScanState>>(failed); return Task.FromResult<IReadOnlyList<ManuscriptScanState>>(failed);
} }
public Task<bool> HasRecentRunningScanForUserAsync(int userId, TimeSpan maxAge)
{
var now = DateTime.UtcNow;
return Task.FromResult(sessions.Any(pair =>
pair.Key.UserId == userId
&& string.Equals(pair.Value.Status, ManuscriptScanStatuses.Running, StringComparison.Ordinal)
&& pair.Value.UpdatedUtc.HasValue
&& now - pair.Value.UpdatedUtc.Value <= maxAge));
}
public Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId) public Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId)
=> Task.FromResult(sessions.TryGetValue((userId, onboardingId), out var session) => Task.FromResult(sessions.TryGetValue((userId, onboardingId), out var session)
? ToState(session) ? ToState(session)
@ -148,15 +240,21 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
public Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId) public Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId)
=> Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null); => Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null);
public Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId) public async Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId)
{ {
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId) if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
{ {
return Task.FromResult<ManuscriptScanReviewDecision?>(null); return null;
} }
var review = reviews.GetOrAdd(previewId, _ => BuildDefaultReview(preview)); if (reviews.TryGetValue(previewId, out var review))
return Task.FromResult<ManuscriptScanReviewDecision?>(review); {
return review;
}
review = await BuildDefaultReviewAsync(preview);
var stored = reviews.GetOrAdd(previewId, review);
return stored;
} }
public Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review) public Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review)
@ -170,6 +268,7 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
{ {
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,
@ -185,6 +284,45 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
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)
@ -228,6 +366,7 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
private static ManuscriptScanState ToState(ScanSession session) private static ManuscriptScanState ToState(ScanSession session)
=> new() => new()
{ {
ScanID = session.ScanID,
Status = session.Status, Status = session.Status,
ReviewStatus = session.ReviewStatus, ReviewStatus = session.ReviewStatus,
Message = session.Message, Message = session.Message,
@ -246,10 +385,12 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
return string.IsNullOrWhiteSpace(cleaned) ? fallback : cleaned; return string.IsNullOrWhiteSpace(cleaned) ? fallback : cleaned;
} }
private static ManuscriptScanReviewDecision BuildDefaultReview(ManuscriptScanPreview preview) private async Task<ManuscriptScanReviewDecision> BuildDefaultReviewAsync(ManuscriptScanPreview preview)
=> new() {
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)
@ -261,18 +402,7 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
ChapterNumber = chapter.ChapterNumber ChapterNumber = chapter.ChapterNumber
}) })
.ToList(), .ToList(),
Scenes = preview.Scenes 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
{ {
@ -284,9 +414,32 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
}) })
.ToList() .ToList()
}; };
}
private async Task<IReadOnlyDictionary<string, string>> GetAiSexHintsAsync(
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates)
{
if (candidates.Count == 0)
{
return new Dictionary<string, string>();
}
try
{
using var scope = scopeFactory.CreateScope();
var hintService = scope.ServiceProvider.GetRequiredService<ICharacterSexHintAiService>();
return await hintService.SuggestAsync(candidates, CancellationToken.None);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not create AI character sex hints for manuscript scan review; using deterministic fallback hints.");
return new Dictionary<string, string>();
}
}
private sealed class ScanSession private sealed class ScanSession
{ {
public Guid ScanID { get; set; }
public int UserID { get; init; } public int UserID { get; init; }
public int OnboardingID { get; init; } public int OnboardingID { get; init; }
public int ProjectID { get; init; } public int ProjectID { get; init; }
@ -301,7 +454,15 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
public int TotalWordCount { get; set; } public int TotalWordCount { get; set; }
public Guid? PreviewID { get; set; } public Guid? PreviewID { get; set; }
public DateTime? UpdatedUtc { get; set; } public DateTime? UpdatedUtc { get; set; }
public int? ExpectedChunkCount { get; set; }
public int? ExpectedPayloadLength { get; set; }
public SortedDictionary<int, string> Chunks { get; } = [];
} }
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
} }
file static class ManuscriptScanPreviewExtensions file static class ManuscriptScanPreviewExtensions
@ -319,11 +480,11 @@ file static class ManuscriptScanPreviewExtensions
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier, CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
TotalWordCount = preview.TotalWordCount, TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount, ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount, SceneCount = 0,
CharacterCandidateCount = preview.CharacterCandidateCount, CharacterCandidateCount = 0,
CreatedUtc = preview.CreatedUtc == default ? DateTime.UtcNow : preview.CreatedUtc, CreatedUtc = preview.CreatedUtc == default ? DateTime.UtcNow : preview.CreatedUtc,
Chapters = preview.Chapters, Chapters = preview.Chapters,
Scenes = preview.Scenes, Scenes = [],
CharacterCandidates = preview.CharacterCandidates CharacterCandidates = []
}; };
} }

View File

@ -0,0 +1,773 @@
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 structuralParagraphResult = BuildStructuralParagraphs(request, text);
if (!structuralParagraphResult.Success)
{
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
structuralParagraphResult.ErrorMessage ?? "The approved chapter paragraph map is not reversible.");
}
var paragraphs = structuralParagraphResult.Paragraphs;
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);
if (normalisation.Issues.Count > 0)
{
logger.LogWarning(
"Structural scene-boundary normalisation warnings. ProjectID={ProjectID} BookID={BookID} PreviewID={PreviewID} TemporaryChapterKey={TemporaryChapterKey} StructuralParagraphCount={StructuralParagraphCount} Warnings={Warnings}",
request.ProjectID,
request.BookID,
request.PreviewID,
request.TemporaryChapterKey,
paragraphs.Count,
string.Join("; ", normalisation.Issues.Select(issue => issue.Message)));
}
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)
{
var diagnostic = BuildMappingFailureDiagnostic(
sceneNumber,
start,
end,
request.ParagraphMap,
request.SourceParagraphs);
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
$"Scene {sceneNumber:N0} could not be mapped back to a Word paragraph. {diagnostic}",
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 StructuralParagraphBuildResult BuildStructuralParagraphs(ManuscriptStructureAnalysisRequest request, string chapterText)
{
if (request.ParagraphMap.Count == 0)
{
return StructuralParagraphBuildResult.Succeeded(StoryIntelligenceParagraphs.Split(chapterText));
}
var ordered = request.ParagraphMap
.OrderBy(item => item.StructuralParagraphOrdinal)
.ToList();
var expectedOrdinal = 1;
var previousWordIndex = -1;
var paragraphs = new List<string>();
foreach (var item in ordered)
{
if (item.StructuralParagraphOrdinal != expectedOrdinal)
{
return StructuralParagraphBuildResult.Failed(
$"Paragraph map is missing structural paragraph {expectedOrdinal:N0}. {BuildMapNeighbourDiagnostic(expectedOrdinal, ordered, request.SourceParagraphs)}");
}
if (item.WordParagraphIndex < 0)
{
return StructuralParagraphBuildResult.Failed(
$"Structural paragraph {expectedOrdinal:N0} has no Word paragraph index. {BuildMapNeighbourDiagnostic(expectedOrdinal, ordered, request.SourceParagraphs)}");
}
if (item.WordParagraphIndex <= previousWordIndex)
{
return StructuralParagraphBuildResult.Failed(
$"Structural paragraph {expectedOrdinal:N0} maps to non-monotonic Word paragraph {item.WordParagraphIndex:N0}. {BuildMapNeighbourDiagnostic(expectedOrdinal, ordered, request.SourceParagraphs)}");
}
var text = NormalizeMappedParagraphText(item.Text);
if (string.IsNullOrWhiteSpace(text))
{
return StructuralParagraphBuildResult.Failed(
$"Structural paragraph {expectedOrdinal:N0} has no mapped text. {BuildMapNeighbourDiagnostic(expectedOrdinal, ordered, request.SourceParagraphs)}");
}
paragraphs.Add(text);
previousWordIndex = item.WordParagraphIndex;
expectedOrdinal++;
}
return StructuralParagraphBuildResult.Succeeded(paragraphs);
}
private static string NormalizeMappedParagraphText(string? value)
=> Regex.Replace(
(value ?? string.Empty)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Replace('\v', '\n')
.Replace('\f', '\n')
.Replace('\u2028', '\n')
.Replace('\u2029', '\n'),
@"\s*\n+\s*",
" ").Trim();
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 BuildMappingFailureDiagnostic(
int sceneNumber,
int structuralStart,
int structuralEnd,
IReadOnlyList<ManuscriptScanParagraphMap> paragraphMap,
IReadOnlyList<ManuscriptScanSourceParagraph> sourceParagraphs)
{
var startMap = paragraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == structuralStart);
var endMap = paragraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == structuralEnd);
var missing = new List<string>();
if (startMap is null || startMap.WordParagraphIndex < 0)
{
missing.Add($"start structural paragraph {structuralStart:N0}");
}
if (endMap is null || endMap.WordParagraphIndex < 0)
{
missing.Add($"end structural paragraph {structuralEnd:N0}");
}
var wordRange = sourceParagraphs.Count == 0
? "unknown"
: $"{sourceParagraphs.Min(item => item.WordParagraphIndex):N0}..{sourceParagraphs.Max(item => item.WordParagraphIndex):N0}";
var nearestOrdinal = missing.Count > 0 && (endMap is null || endMap.WordParagraphIndex < 0)
? structuralEnd
: structuralStart;
return $"Scene {sceneNumber:N0} structural range {structuralStart:N0}-{structuralEnd:N0}; missing {string.Join(" and ", missing)}; {BuildMapNeighbourDiagnostic(nearestOrdinal, paragraphMap, sourceParagraphs)}; chapter Word range {wordRange}.";
}
private static string BuildMapNeighbourDiagnostic(
int structuralOrdinal,
IReadOnlyList<ManuscriptScanParagraphMap> paragraphMap,
IReadOnlyList<ManuscriptScanSourceParagraph> sourceParagraphs)
{
var previous = paragraphMap
.Where(item => item.StructuralParagraphOrdinal < structuralOrdinal && item.WordParagraphIndex >= 0)
.OrderByDescending(item => item.StructuralParagraphOrdinal)
.FirstOrDefault();
var next = paragraphMap
.Where(item => item.StructuralParagraphOrdinal > structuralOrdinal && item.WordParagraphIndex >= 0)
.OrderBy(item => item.StructuralParagraphOrdinal)
.FirstOrDefault();
var wordRange = sourceParagraphs.Count == 0
? "unknown"
: $"{sourceParagraphs.Min(item => item.WordParagraphIndex):N0}..{sourceParagraphs.Max(item => item.WordParagraphIndex):N0}";
return $"nearest mapped neighbours: previous {Display(previous?.StructuralParagraphOrdinal)}->{Display(previous?.WordParagraphIndex)}, next {Display(next?.StructuralParagraphOrdinal)}->{Display(next?.WordParagraphIndex)}, chapter Word range {wordRange}";
}
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 };
}
internal sealed class StructuralParagraphBuildResult
{
public bool Success { get; init; }
public IReadOnlyList<string> Paragraphs { get; init; } = [];
public string? ErrorMessage { get; init; }
public static StructuralParagraphBuildResult Succeeded(IReadOnlyList<string> paragraphs)
=> new() { Success = true, Paragraphs = paragraphs };
public static StructuralParagraphBuildResult Failed(string errorMessage)
=> new() { Success = false, ErrorMessage = errorMessage };
}
public sealed class ManuscriptStructureSceneBoundary
{
public string TemporarySceneKey { get; init; } = string.Empty;
public string TemporaryChapterKey { get; init; } = string.Empty;
public int SceneNumberWithinChapter { get; init; }
public string Title { get; init; } = string.Empty;
public string? SuggestedTitle => SceneTitleDisplay.Clean(Title);
public int WordCount { get; init; }
public string SourceText { get; init; } = string.Empty;
public int StartParagraph { get; init; }
public int EndParagraph { get; init; }
public int WordStartPosition { get; init; }
public int WordEndPosition { get; init; }
public string? StructuralSummary { get; init; }
public string? StructuralSetting { get; init; }
public string? Reason { get; init; }
public decimal? Confidence { get; init; }
public string? SuggestedPovName { get; init; }
public decimal? SuggestedPovConfidence { get; init; }
public string? SuggestedPovEvidence { get; init; }
public bool SuggestedPovIsOverride { get; init; }
public string? ChapterParsedJson { get; init; }
public string? ExplicitNarratorName { get; init; }
}

View File

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

View File

@ -0,0 +1,597 @@
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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,43 @@
using Microsoft.Extensions.Options;
using PlotLine.Models;
namespace PlotLine.Services; namespace PlotLine.Services;
public sealed class PersistedStoryIntelligenceWorker( public sealed class PersistedStoryIntelligenceWorker(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IOptions<StoryIntelligenceOptions> options,
ILogger<PersistedStoryIntelligenceWorker> logger) : BackgroundService ILogger<PersistedStoryIntelligenceWorker> logger) : BackgroundService
{ {
private readonly StoryIntelligenceOptions settings = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); var workerCount = Math.Clamp(settings.MaxGlobalConcurrentAiRequests, 1, 24);
logger.LogInformation(
"Starting {WorkerCount} persisted Story Intelligence worker loop(s). MaxPerBook={MaxPerBook} MaxPerUser={MaxPerUser} PollSeconds={PollSeconds} ClaimLeaseMinutes={ClaimLeaseMinutes}",
workerCount,
Math.Max(1, settings.MaxConcurrentAiRequestsPerBook),
Math.Max(1, settings.MaxConcurrentAiRequestsPerUser),
Math.Max(1, settings.QueuePollingIntervalSeconds),
Math.Max(15, settings.ClaimLeaseMinutes));
var workers = Enumerable.Range(1, workerCount)
.Select(workerNumber => RunWorkerLoopAsync(workerNumber, stoppingToken))
.ToArray();
await Task.WhenAll(workers);
}
private async Task RunWorkerLoopAsync(int workerNumber, CancellationToken stoppingToken)
{
var pollDelay = TimeSpan.FromSeconds(Math.Max(1, settings.QueuePollingIntervalSeconds));
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
var processedRun = false;
try try
{ {
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
var runner = scope.ServiceProvider.GetRequiredService<IPersistedStoryIntelligenceRunner>(); var runner = scope.ServiceProvider.GetRequiredService<IPersistedStoryIntelligenceRunner>();
await runner.ProcessNextAsync(stoppingToken); processedRun = await runner.ProcessNextAsync(stoppingToken);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
@ -21,10 +45,59 @@ public sealed class PersistedStoryIntelligenceWorker(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Persisted Story Intelligence worker failed while processing the next queued run."); logger.LogError(ex, "Persisted Story Intelligence worker {WorkerNumber} failed while processing the next queued run.", workerNumber);
} }
await timer.WaitForNextTickAsync(stoppingToken); if (!processedRun)
{
await RecoverReadySceneAnalysisAsync(stoppingToken);
processedRun = await ProcessWholeBookPlotSynthesisAsync(stoppingToken);
} }
if (!processedRun)
{
await Task.Delay(pollDelay, stoppingToken);
}
}
}
private async Task<bool> ProcessWholeBookPlotSynthesisAsync(CancellationToken stoppingToken)
{
using var scope = scopeFactory.CreateScope();
var synthesis = scope.ServiceProvider.GetRequiredService<IWholeBookPlotIntelligenceService>();
return await synthesis.ProcessNextAsync(stoppingToken);
}
private async Task RecoverReadySceneAnalysisAsync(CancellationToken stoppingToken)
{
using var scope = scopeFactory.CreateScope();
var pipelineState = scope.ServiceProvider.GetRequiredService<IStoryIntelligencePipelineStateService>();
var materialisation = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceMaterialisationService>();
var notifier = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceProgressNotifier>();
var completionNotifications = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceCompletionNotificationService>();
var candidate = await pipelineState.FindNextReadySceneAnalysisAsync();
if (candidate is null)
{
return;
}
var materialised = await materialisation.MaterialiseReadyBookAsync(candidate.BookID, candidate.UserID, stoppingToken);
if (!materialised.IsReady)
{
logger.LogInformation(
"Story Intelligence recovery finalisation deferred for BookID={BookID}. Reason={Reason}",
candidate.BookID,
materialised.Reason);
return;
}
var completion = await pipelineState.CompleteSceneAnalysisIfReadyAsync(candidate.BookID, candidate.UserID);
if (completion is not { CompletedNow: true })
{
return;
}
await notifier.PublishGlobalAsync(completion.UserID);
await completionNotifications.NotifyIfImportTerminalAsync(completion.BookID, completion.UserID, stoppingToken);
} }
} }

View File

@ -90,6 +90,7 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
["Dynamics"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["bookId"] = "Book", ["sceneId"] = "Scene" }, ["Dynamics"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["bookId"] = "Book", ["sceneId"] = "Scene" },
["Exports"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["sceneId"] = "Scene" }, ["Exports"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project", ["sceneId"] = "Scene" },
["Scenes"] = new(StringComparer.OrdinalIgnoreCase) { ["chapterId"] = "Chapter", ["sceneId"] = "Scene", ["projectId"] = "Project", ["bookId"] = "Book" }, ["Scenes"] = new(StringComparer.OrdinalIgnoreCase) { ["chapterId"] = "Chapter", ["sceneId"] = "Scene", ["projectId"] = "Project", ["bookId"] = "Book" },
["Onboarding"] = new(StringComparer.OrdinalIgnoreCase) { ["bookId"] = "Book" },
["Acceptance"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" }, ["Acceptance"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" },
["StoryBible"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" } ["StoryBible"] = new(StringComparer.OrdinalIgnoreCase) { ["projectId"] = "Project" }
}; };

View File

@ -34,10 +34,11 @@ public sealed class StoryIntelligenceAssetImportService(
"car", "vehicle", "coat", "dress", "shoes", "shirt", "trousers", "weather", "rain", "food", "drink"); "car", "vehicle", "coat", "dress", "shoes", "shirt", "trousers", "weather", "rain", "food", "drink");
private static readonly HashSet<string> AcceptedAssetNames = CreateSet( private static readonly HashSet<string> AcceptedAssetNames = CreateSet(
"tr6", "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note", "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note",
"notebook", "red notebook", "old notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring", "notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring",
"ring", "necklace", "cassette tape", "camera", "map", "diary", "watch", "driving licence", "ring", "necklace", "cassette tape", "camera", "map", "diary", "watch", "driving licence",
"driving license", "licence", "license", "document", "folder", "wallet", "bank book", "scrap of paper"); "driving license", "licence", "license", "document", "folder", "wallet", "bank book", "address book", "scrap of paper",
"keepsake box", "keepsake tin");
private static readonly HashSet<string> StorySignals = CreateSet( private static readonly HashSet<string> StorySignals = CreateSet(
"lost", "found", "hidden", "stolen", "missing", "taken", "given", "carried", "used", "opened", "lost", "found", "hidden", "stolen", "missing", "taken", "given", "carried", "used", "opened",
@ -47,8 +48,25 @@ public sealed class StoryIntelligenceAssetImportService(
private static readonly HashSet<string> AdjectivesToFold = CreateSet( private static readonly HashSet<string> AdjectivesToFold = CreateSet(
"red", "old", "new", "small", "large", "little", "anonymous", "folded", "sealed", "missing"); "red", "old", "new", "small", "large", "little", "anonymous", "folded", "sealed", "missing");
private static readonly HashSet<string> RelationshipWords = CreateSet(
"mother", "father", "mum", "mom", "dad", "sister", "brother", "parent", "parents", "daughter", "son",
"child", "children", "aunt", "uncle", "wife", "husband", "partner", "friend", "neighbour", "neighbor",
"teacher", "student", "examiner", "doctor", "nurse");
public async Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) public async Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{ {
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
if (batch.AssetStageComplete || pipeline?.AssetReviewCompletedUtc is not null)
{
return new StoryIntelligenceAssetReviewViewModel
{
HasCommittedScenes = true,
CanImport = false,
IsComplete = true,
Candidates = []
};
}
var data = await BuildCandidateDataAsync(batch); var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.AssetDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var decidedKeys = batch.AssetDecisions.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();
@ -81,7 +99,7 @@ public sealed class StoryIntelligenceAssetImportService(
var data = await BuildCandidateDataAsync(batch); var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes) if (!data.HasCommittedScenes)
{ {
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing assets." }; return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing assets." };
} }
var choices = form.Assets var choices = form.Assets
@ -310,21 +328,28 @@ public sealed class StoryIntelligenceAssetImportService(
foreach (var item in batch.Items) foreach (var item in batch.Items)
{ {
var commit = await runs.GetImportCommitAsync(item.RunID); var run = await runs.GetRunAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{ {
continue; continue;
} }
hasCommittedScenes = true; hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
.Where(scene => scene.ImportRunID == item.RunID) var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
.ToList(); ? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).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);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults) foreach (var sceneResult in sceneResults)
{ {
@ -334,7 +359,7 @@ public sealed class StoryIntelligenceAssetImportService(
continue; continue;
} }
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
if (importedScene is null) if (importedScene is null)
{ {
continue; continue;
@ -363,6 +388,8 @@ public sealed class StoryIntelligenceAssetImportService(
SceneIntelligenceScene parsed, SceneIntelligenceScene parsed,
IReadOnlyList<AssetEvent> existingEvents) IReadOnlyList<AssetEvent> existingEvents)
{ {
AddStructuralSummaryAssets(groups, existingIndex, importedScene, parsed, existingEvents);
foreach (var asset in parsed.Assets ?? []) foreach (var asset in parsed.Assets ?? [])
{ {
AddAsset(groups, existingIndex, importedScene, parsed, asset, existingEvents); AddAsset(groups, existingIndex, importedScene, parsed, asset, existingEvents);
@ -405,8 +432,8 @@ public sealed class StoryIntelligenceAssetImportService(
SceneIntelligenceAsset asset, SceneIntelligenceAsset asset,
IReadOnlyList<AssetEvent> existingEvents) IReadOnlyList<AssetEvent> existingEvents)
{ {
var cleanName = CleanAssetName(asset.Name); var cleanName = ResolveContextualAssetName(asset.Name, asset.OwnerOrHolder, importedScene);
if (!IsAssetNameCandidate(cleanName)) if (!IsAssetNameCandidate(cleanName) && !CanAttachToExistingContextualAsset(groups, cleanName))
{ {
return; return;
} }
@ -428,7 +455,7 @@ public sealed class StoryIntelligenceAssetImportService(
candidate.Confidence = Max(candidate.Confidence, asset.Confidence); candidate.Confidence = Max(candidate.Confidence, asset.Confidence);
candidate.Description ??= Clean(asset.Notes); candidate.Description ??= Clean(asset.Notes);
candidate.OwnerOrHolder ??= Clean(asset.OwnerOrHolder); candidate.OwnerOrHolder ??= ResolveOwnerName(asset.OwnerOrHolder, importedScene);
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName); candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName);
candidate.Appearances.Add(new AssetAppearanceImport( candidate.Appearances.Add(new AssetAppearanceImport(
importedScene.SceneID, importedScene.SceneID,
@ -441,6 +468,61 @@ public sealed class StoryIntelligenceAssetImportService(
candidate.ExistingAssetID.HasValue && existingEvents.Any(existing => existing.StoryAssetID == candidate.ExistingAssetID.Value))); candidate.ExistingAssetID.HasValue && existingEvents.Any(existing => existing.StoryAssetID == candidate.ExistingAssetID.Value)));
} }
private static void AddStructuralSummaryAssets(
Dictionary<string, AssetCandidate> groups,
AssetIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<AssetEvent> existingEvents)
{
var source = FirstConfigured(importedScene.StructuralSummary, importedScene.SceneTitle);
if (string.IsNullOrWhiteSpace(source))
{
return;
}
foreach (Match match in StructuralPossessiveAssetPattern.Matches(source))
{
var owner = match.Groups["owner"].Value;
var objectName = CleanStructuralAssetPhrase(match.Groups["asset"].Value);
if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(objectName))
{
continue;
}
var name = $"{owner}'s {objectName}";
AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset
{
Name = name,
OwnerOrHolder = owner,
Status = "structuralSummarySignificance",
MentionedOnly = true,
Confidence = 0.86m,
Notes = $"Structural summary identifies {name}."
}, existingEvents);
}
foreach (Match match in StructuralSignificantAssetPattern.Matches(source))
{
var objectName = CleanStructuralAssetPhrase(match.Groups["asset"].Value);
if (string.IsNullOrWhiteSpace(objectName) || !IsOwnedAssetName(objectName))
{
continue;
}
var owner = Clean(importedScene.POVCharacterName ?? importedScene.POVSuggestedName);
AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset
{
Name = objectName,
OwnerOrHolder = owner,
Status = "structuralSummarySignificance",
MentionedOnly = true,
Confidence = 0.82m,
Notes = $"Structural summary identifies {objectName} as significant."
}, existingEvents);
}
}
private async Task<AssetIndex> BuildAssetIndexAsync(int projectId) private async Task<AssetIndex> BuildAssetIndexAsync(int projectId)
{ {
var index = new AssetIndex(); var index = new AssetIndex();
@ -492,9 +574,15 @@ 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))
@ -592,9 +680,15 @@ public sealed class StoryIntelligenceAssetImportService(
var sceneCount = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(); var sceneCount = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count();
var signalText = $"{candidate.DisplayName} {candidate.Category} {candidate.Description} {candidate.OwnerOrHolder} {string.Join(' ', candidate.Appearances.Select(a => a.Notes))}"; var signalText = $"{candidate.DisplayName} {candidate.Category} {candidate.Description} {candidate.OwnerOrHolder} {string.Join(' ', candidate.Appearances.Select(a => a.Notes))}";
return sceneCount >= 2 && ContainsStoryAssetSignal(signalText); return ContainsStructuralSignificance(candidate)
|| sceneCount >= 2 && (ContainsStoryAssetSignal(signalText) || IsDistinctiveAssetName(candidate.DisplayName));
} }
private static bool ContainsStructuralSignificance(AssetCandidate candidate)
=> candidate.Appearances.Any(appearance => string.Equals(appearance.EventType, "Mentioned", StringComparison.OrdinalIgnoreCase)
&& IsStructuralSummaryEvidence(appearance.Notes)
&& IsDistinctiveAssetName(candidate.DisplayName));
private static bool IsAssetNameCandidate(string? name) private static bool IsAssetNameCandidate(string? name)
{ {
var clean = CleanAssetName(name); var clean = CleanAssetName(name);
@ -609,7 +703,9 @@ public sealed class StoryIntelligenceAssetImportService(
} }
var normalised = Normalise(clean); var normalised = Normalise(clean);
return !GenericObjects.Contains(normalised) && !GenericObjects.Contains(RemoveLeadingArticle(normalised)); return !GenericObjects.Contains(normalised)
&& !GenericObjects.Contains(RemoveLeadingArticle(normalised))
&& !LooksLikePersonOrRelationshipFact(clean);
} }
private static bool IsAcceptedStoryAssetName(string? name) private static bool IsAcceptedStoryAssetName(string? name)
@ -618,7 +714,7 @@ public sealed class StoryIntelligenceAssetImportService(
var withoutArticle = RemoveLeadingArticle(normalised); var withoutArticle = RemoveLeadingArticle(normalised);
return AcceptedAssetNames.Contains(normalised) return AcceptedAssetNames.Contains(normalised)
|| AcceptedAssetNames.Contains(withoutArticle) || AcceptedAssetNames.Contains(withoutArticle)
|| normalised.Contains("tr6", StringComparison.OrdinalIgnoreCase) || normalised.Contains(" keepsake ", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains("'s car", StringComparison.OrdinalIgnoreCase) || normalised.Contains("'s car", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains("’s car", StringComparison.OrdinalIgnoreCase); || normalised.Contains("’s car", StringComparison.OrdinalIgnoreCase);
} }
@ -655,7 +751,7 @@ public sealed class StoryIntelligenceAssetImportService(
return "Evidence"; return "Evidence";
} }
if (value.Contains("car") || value.Contains("tr6") || value.Contains("vehicle")) if (value.Contains("car") || value.Contains("vehicle"))
{ {
return "Vehicle"; return "Vehicle";
} }
@ -680,17 +776,99 @@ public sealed class StoryIntelligenceAssetImportService(
} }
var canonical = CanonicalAssetKey(name); var canonical = CanonicalAssetKey(name);
return groups.ContainsKey(canonical) ? canonical : canonical; if (groups.ContainsKey(canonical))
{
return canonical;
} }
var contextual = FindContextualAssetKey(groups.Keys, canonical);
return contextual ?? canonical;
}
private static string? FindContextualAssetKey(IEnumerable<string> existingKeys, string candidateKey)
{
if (string.IsNullOrWhiteSpace(candidateKey)
|| HasPossessiveOwner(candidateKey)
|| LooksLikePersonOrRelationshipFact(candidateKey))
{
return null;
}
var matches = existingKeys
.Where(key => key.EndsWith($" {candidateKey}", StringComparison.OrdinalIgnoreCase))
.ToList();
return matches.Count == 1 ? matches[0] : null;
}
private static bool CanAttachToExistingContextualAsset(IReadOnlyDictionary<string, AssetCandidate> groups, string name)
=> FindContextualAssetKey(groups.Keys, CanonicalAssetKey(name)) is not null;
private static string ResolveContextualAssetName(string? name, string? ownerOrHolder, Scene scene)
{
var cleanName = CleanAssetName(name);
if (string.IsNullOrWhiteSpace(cleanName))
{
return cleanName;
}
var owner = ResolveOwnerName(ownerOrHolder, scene);
if (cleanName.StartsWith("my ", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(scene.POVCharacterName))
{
return $"{scene.POVCharacterName}'s {cleanName[3..].Trim()}";
}
if (!string.IsNullOrWhiteSpace(owner)
&& !cleanName.Contains("'s", StringComparison.OrdinalIgnoreCase)
&& !cleanName.Contains("’s", StringComparison.OrdinalIgnoreCase)
&& IsOwnedAssetName(cleanName))
{
return $"{owner}'s {cleanName}";
}
return cleanName;
}
private static string ResolveOwnerName(string? ownerOrHolder, Scene scene)
{
var owner = Clean(ownerOrHolder);
if (string.IsNullOrWhiteSpace(owner))
{
return owner;
}
if (string.Equals(owner, "Narrator", StringComparison.OrdinalIgnoreCase)
|| string.Equals(owner, "me", StringComparison.OrdinalIgnoreCase)
|| string.Equals(owner, "my", StringComparison.OrdinalIgnoreCase))
{
return Clean(scene.POVCharacterName ?? scene.POVSuggestedName);
}
return owner;
}
private static bool IsOwnedAssetName(string name)
=> ContainsStoryAssetSignal(name)
|| IsAcceptedStoryAssetName(name)
|| IsDistinctiveAssetName(name);
private static bool HasPossessiveOwner(string value)
=> value.Contains("'s ", StringComparison.OrdinalIgnoreCase)
|| value.Contains("’s ", StringComparison.OrdinalIgnoreCase);
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName) private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName)
{ {
var summary = Clean(parsed.Summary?.Short); var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
return string.IsNullOrWhiteSpace(summary) return string.IsNullOrWhiteSpace(summary)
? $"Scene {scene.SceneNumber:g}: {assetName}" ? $"{BuildSceneReference(scene)}: {assetName}"
: $"Scene {scene.SceneNumber:g}: {summary}"; : $"{BuildSceneReference(scene)}: {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));
@ -729,6 +907,12 @@ public sealed class StoryIntelligenceAssetImportService(
private static string CanonicalAssetKey(string name) private static string CanonicalAssetKey(string name)
{ {
var clean = RemoveLeadingArticle(Normalise(CleanAssetName(name))); var clean = RemoveLeadingArticle(Normalise(CleanAssetName(name)));
var modelCode = ExtractDistinctiveModelCode(clean);
if (!string.IsNullOrWhiteSpace(modelCode))
{
return modelCode;
}
var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
while (words.Count > 1 && AdjectivesToFold.Contains(words[0])) while (words.Count > 1 && AdjectivesToFold.Contains(words[0]))
{ {
@ -754,7 +938,94 @@ public sealed class StoryIntelligenceAssetImportService(
} }
private static string CleanAssetName(string? value) private static string CleanAssetName(string? value)
=> Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\''); => StoryIntelligenceEntityTextNormaliser.CleanName(value);
private static bool IsStructuralSummaryEvidence(string? value)
=> Clean(value).Contains("Structural summary identifies", StringComparison.OrdinalIgnoreCase);
private static bool IsDistinctiveAssetName(string? value)
{
var clean = RemoveLeadingArticle(Normalise(CleanAssetName(value)));
if (string.IsNullOrWhiteSpace(clean) || GenericObjects.Contains(clean) || LooksLikePersonOrRelationshipFact(clean))
{
return false;
}
if (!string.IsNullOrWhiteSpace(ExtractDistinctiveModelCode(clean)))
{
return true;
}
if (clean.Contains("'s ", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("’s ", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return words.Length >= 2
&& words.Any(word => !AdjectivesToFold.Contains(word) && !GenericObjects.Contains(word));
}
private static bool LooksLikePersonOrRelationshipFact(string? value)
{
var clean = RemoveLeadingArticle(Normalise(CleanAssetName(value)));
if (string.IsNullOrWhiteSpace(clean) || IsAcceptedStoryAssetName(clean))
{
return false;
}
if (clean.Contains(" dead in ", StringComparison.OrdinalIgnoreCase)
|| clean.Contains(" in bed", StringComparison.OrdinalIgnoreCase)
|| clean.Contains(" before leaving", StringComparison.OrdinalIgnoreCase)
|| clean.Contains(" written name", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var subject = StripPossessiveOwner(clean);
var words = subject.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return words.Length is >= 1 and <= 4
&& words.Any(word => RelationshipWords.Contains(word))
&& !words.Any(word => AcceptedAssetNames.Contains(word) || StorySignals.Contains(word));
}
private static string StripPossessiveOwner(string value)
{
var straightIndex = value.IndexOf("'s ", StringComparison.OrdinalIgnoreCase);
var curlyIndex = value.IndexOf("’s ", StringComparison.OrdinalIgnoreCase);
var index = straightIndex >= 0
? straightIndex
: curlyIndex;
return index >= 0 ? value[(index + 3)..] : value;
}
private static string? ExtractDistinctiveModelCode(string? value)
{
var clean = Normalise(value);
var matches = Regex.Matches(clean, @"\b(?=[a-z0-9]*[a-z])(?=[a-z0-9]*\d)[a-z]{1,6}\d[a-z0-9]{0,6}\b", RegexOptions.IgnoreCase);
return matches.Count == 1 ? matches[0].Value : null;
}
private static string CleanStructuralAssetPhrase(string? value)
{
var clean = CleanAssetName(value);
if (string.IsNullOrWhiteSpace(clean))
{
return string.Empty;
}
foreach (var separator in StructuralAssetTailSeparators)
{
var index = clean.IndexOf(separator, StringComparison.OrdinalIgnoreCase);
if (index > 0)
{
clean = clean[..index].Trim();
}
}
return clean;
}
private static string RemoveLeadingArticle(string value) private static string RemoveLeadingArticle(string value)
=> value.StartsWith("the ", StringComparison.OrdinalIgnoreCase) => value.StartsWith("the ", StringComparison.OrdinalIgnoreCase)
@ -766,14 +1037,36 @@ public sealed class StoryIntelligenceAssetImportService(
: value; : value;
private static string Normalise(string? value) private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant(); => StoryIntelligenceEntityTextNormaliser.SafeKey(value);
private static string Clean(string? value) private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); => StoryIntelligenceEntityTextNormaliser.Clean(value);
private static HashSet<string> CreateSet(params string[] values) private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase); => values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
private static readonly string[] StructuralAssetTailSeparators =
[
" and ",
" or ",
" while ",
" that ",
" which ",
" containing ",
" contains ",
" with ",
" inside ",
","
];
private static readonly Regex StructuralPossessiveAssetPattern = new(
@"\b(?<owner>[A-Z][\p{L}'-]{1,40})'s\s+(?<asset>[A-Z][\p{L}\p{N}'-]*(?:\s+[A-Z][\p{L}\p{N}'-]*){0,3})\b",
RegexOptions.Compiled);
private static readonly Regex StructuralSignificantAssetPattern = new(
@"\b(?:opens?|retrieves?|discovers?|examines?|keeps?|hides?|finds?|carries?|takes?|gives?|receives?|reads?)\s+(?:her|his|their|my|the|a|an)\s+(?<asset>[\p{L}\p{N}'-]+(?:\s+[\p{L}\p{N}'-]+){0,4})\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private sealed class AssetIndex private sealed class AssetIndex
{ {
private readonly Dictionary<string, AssetMatch> byName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, AssetMatch> byName = new(StringComparer.OrdinalIgnoreCase);

View File

@ -17,6 +17,10 @@ public sealed class StoryIntelligenceCharacterImportService(
ICharacterRepository characters, ICharacterRepository characters,
ISceneRepository scenes, ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
INameIntelligenceService nameIntelligence,
ICoreImportCharacterMappingService coreImportCharacterMapping,
IWholeBookPlotIntelligenceService wholeBookPlotIntelligence,
ICurrentUserService currentUser,
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@ -67,36 +71,133 @@ public sealed class StoryIntelligenceCharacterImportService(
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{ {
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
if (batch.CharacterStageComplete || pipeline?.CharacterReviewCompletedUtc is not null)
{
return new StoryIntelligenceCharacterReviewViewModel
{
HasCommittedScenes = true,
CanImport = false,
IsComplete = true,
Candidates = []
};
}
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,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel GenericReferenceCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.GenericReference),
ExistingMatchCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.ExistingCharacter),
NewCandidateCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.NewCharacter || item.Classification == StoryIntelligenceCharacterClassifications.NewAlias),
AmbiguousCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.Ambiguous),
AliasTargetOptions = existingOptions,
Candidates = classified.Select(item => new StoryIntelligenceCharacterReviewCandidateViewModel
{ {
Key = candidate.Key, DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(item.Candidate.DisplayName, item.Candidate.ExistingCharacterID, item.Classification),
CharacterName = candidate.DisplayName, Classification = item.Classification,
ImportName = candidate.DisplayName, Key = item.Candidate.Key,
AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(), CharacterName = item.Candidate.DisplayName,
Confidence = ConfidenceBand(candidate.Confidence), ImportName = item.Candidate.DisplayName,
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(), AppearsInScenes = item.Candidate.PresentAppearances.Concat(item.Candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
ExampleFirstAppearance = candidate.FirstAppearanceNote, Confidence = ConfidenceBand(item.Candidate.Confidence),
ExistingCharacterID = candidate.ExistingCharacterID, PossibleAliases = item.Candidate.Aliases.OrderBy(value => value).ToList(),
ExistingCharacterName = candidate.ExistingCharacterName ExampleFirstAppearance = item.Candidate.FirstAppearanceNote,
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);
if (!data.HasCommittedScenes) if (!data.HasCommittedScenes)
{ {
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing characters." }; return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing characters." };
} }
var choices = form.Characters var choices = form.Characters
@ -144,7 +245,8 @@ 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
? candidate.ExistingCharacterID ? existingIndex.Find(choice.ExistingCharacterID)?.CharacterID
?? candidate.ExistingCharacterID
?? existingIndex.Find(importName)?.CharacterID ?? existingIndex.Find(importName)?.CharacterID
?? existingIndex.Find(candidate.DisplayName)?.CharacterID ?? existingIndex.Find(candidate.DisplayName)?.CharacterID
: null; : null;
@ -199,29 +301,47 @@ 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 var targetCandidate) && candidateByKey.TryGetValue(targetKey, out targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetCharacterId)) && resolvedCandidateIds.TryGetValue(targetKey, out var resolvedTargetCharacterId))
{
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 target {TargetKey} was not a resolved create/link target.", "Story Intelligence character alias candidate {CandidateKey} could not be imported because no canonical target was selected. AliasTargetCharacterID={AliasTargetCharacterID}",
candidate.Key, candidate.Key,
targetKey); choice.AliasTargetCharacterID);
continue; continue;
} }
var targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName; targetName ??= $"Character {targetCharacterId.Value:N0}";
await TryAddAliasAsync(targetCharacterId, candidate.DisplayName, targetName); if (candidate.ExistingCharacterID.HasValue && candidate.ExistingCharacterID.Value != targetCharacterId.Value)
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, alias, targetName); await TryAddAliasAsync(targetCharacterId.Value, alias, targetName);
AddResolvedName(resolvedCharacters, alias, targetCharacterId); AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value);
} }
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))
@ -229,16 +349,16 @@ public sealed class StoryIntelligenceCharacterImportService(
await characters.SaveSceneCharacterAsync(new SceneCharacter await characters.SaveSceneCharacterAsync(new SceneCharacter
{ {
SceneID = appearance.SceneID, SceneID = appearance.SceneID,
CharacterID = targetCharacterId, CharacterID = targetCharacterId.Value,
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene), RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly), PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetCandidate.DisplayName, appearance.Notes) AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetName, appearance.Notes)
}); });
linkedAppearances++; linkedAppearances++;
} }
aliased++; aliased++;
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId, true); AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId.Value, true);
} }
foreach (var scene in data.SceneAnalyses) foreach (var scene in data.SceneAnalyses)
@ -263,23 +383,27 @@ 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);
await wholeBookPlotIntelligence.QueueAfterCharacterResolutionAsync(batch.ProjectID, batch.BookID, batch.UserID);
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}", "Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks} RemappedScenes={RemappedScenes} RemappedLinks={RemappedLinks}",
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." 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)."
}; };
} }
@ -306,21 +430,28 @@ public sealed class StoryIntelligenceCharacterImportService(
foreach (var item in batch.Items) foreach (var item in batch.Items)
{ {
var commit = await runs.GetImportCommitAsync(item.RunID); var run = await runs.GetRunAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{ {
continue; continue;
} }
hasCommittedScenes = true; hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
.Where(scene => scene.ImportRunID == item.RunID) var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
.ToList(); ? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).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);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults) foreach (var sceneResult in sceneResults)
{ {
@ -330,7 +461,7 @@ public sealed class StoryIntelligenceCharacterImportService(
continue; continue;
} }
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
if (importedScene is null) if (importedScene is null)
{ {
continue; continue;
@ -432,8 +563,13 @@ 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)) if (IsCharacterNameCandidate(povName) && !importedScene.POVCharacterID.HasValue)
{ {
AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter
{ {
@ -471,6 +607,31 @@ 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,
@ -484,7 +645,7 @@ public sealed class StoryIntelligenceCharacterImportService(
IReadOnlyList<SceneCharacter> existingAppearances) IReadOnlyList<SceneCharacter> existingAppearances)
{ {
var cleanName = CleanCharacterName(name); var cleanName = CleanCharacterName(name);
if (!IsCharacterNameCandidate(cleanName)) if (!IsCharacterNameCandidate(cleanName) || IsResolvedPovReference(cleanName, importedScene))
{ {
return; return;
} }
@ -499,11 +660,16 @@ 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);
@ -536,9 +702,15 @@ 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))
@ -616,13 +788,19 @@ public sealed class StoryIntelligenceCharacterImportService(
var note = Clean(character.Notes); var note = Clean(character.Notes);
if (!string.IsNullOrWhiteSpace(note)) if (!string.IsNullOrWhiteSpace(note))
{ {
return $"Scene {scene.SceneNumber:g}: {note}"; return $"{BuildSceneReference(scene)}: {note}";
} }
var summary = Clean(parsed.Summary?.Short); var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
return string.IsNullOrWhiteSpace(summary) ? $"Scene {scene.SceneNumber:g}: {scene.SceneTitle}" : $"Scene {scene.SceneNumber:g}: {summary}"; return string.IsNullOrWhiteSpace(summary) ? $"{BuildSceneReference(scene)}: {scene.SceneTitle}" : $"{BuildSceneReference(scene)}: {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;
@ -671,6 +849,39 @@ 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);
@ -877,6 +1088,21 @@ 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)
{ {
@ -884,14 +1110,20 @@ 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)
{ {

View File

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

View File

@ -10,23 +10,21 @@ 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)
{ {
var deleteCommand = new CommandDefinition( logger.LogWarning("Admin Story Intelligence reset hard-deleting archived project {ProjectID} for user {UserId}.", projectId, userId);
"dbo.ProjectHardDelete_ForOwner", await projects.HardDeleteForOwnerAsync(projectId, userId);
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);
@ -144,28 +142,16 @@ 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);
DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments IF OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'U') IS NOT NULL
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID) BEGIN
DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID)
OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceCharacterIllustrationAssignments.ProjectID); 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()),

View File

@ -0,0 +1,65 @@
using System.Text.RegularExpressions;
namespace PlotLine.Services;
public static class StoryIntelligenceEntityTextNormaliser
{
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
private static readonly Regex TitleFullStops = new(@"\b(Mr|Mrs|Ms|Miss|Dr|Prof|Rev|Revd)\.", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex NonKeyCharacters = new(@"[^\p{L}\p{N}']+", RegexOptions.Compiled);
public static string Clean(string? value)
=> Whitespace.Replace((value ?? string.Empty).Replace('’', '\''), " ").Trim();
public static string CleanName(string? value)
=> Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\'');
public static string StripLeadingArticle(string value)
{
var clean = Clean(value);
return clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase)
? clean[4..]
: clean.StartsWith("a ", StringComparison.OrdinalIgnoreCase)
? clean[2..]
: clean.StartsWith("an ", StringComparison.OrdinalIgnoreCase)
? clean[3..]
: clean;
}
public static string SafeKey(string? value)
{
var clean = TitleFullStops.Replace(Clean(value), "$1");
clean = NonKeyCharacters.Replace(clean, " ");
clean = Whitespace.Replace(clean, " ").Trim().ToLowerInvariant();
return clean;
}
public static string SingulariseSafeFinalWord(string value, IReadOnlySet<string> singularisableWords)
{
var words = SafeKey(value).Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
if (words.Count == 0)
{
return string.Empty;
}
var last = words[^1];
if (last.EndsWith("ies", StringComparison.OrdinalIgnoreCase))
{
var singular = $"{last[..^3]}y";
if (singularisableWords.Contains(singular))
{
words[^1] = singular;
}
}
else if (last.EndsWith('s') && last.Length > 3)
{
var singular = last[..^1];
if (singularisableWords.Contains(singular))
{
words[^1] = singular;
}
}
return string.Join(' ', words);
}
}

View File

@ -14,6 +14,7 @@ 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.";
@ -26,6 +27,7 @@ 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(

View File

@ -19,6 +19,7 @@ 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;
@ -74,6 +75,7 @@ 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))
{ {

View File

@ -0,0 +1,52 @@
using PlotLine.Data;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceGlobalProgressService
{
Task<StoryIntelligenceGlobalProgressViewModel> GetForCurrentUserAsync();
Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId);
}
public sealed class StoryIntelligenceGlobalProgressService(
ICurrentUserService currentUser,
IStoryIntelligenceResultRepository runs) : IStoryIntelligenceGlobalProgressService
{
public async Task<StoryIntelligenceGlobalProgressViewModel> GetForCurrentUserAsync()
{
if (currentUser.UserId is not int userId)
{
return new StoryIntelligenceGlobalProgressViewModel();
}
return await GetForUserAsync(userId);
}
public async Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId)
{
var summaries = await runs.ListActiveBookRunSummariesForUserAsync(userId);
return new StoryIntelligenceGlobalProgressViewModel
{
Books = summaries.Select(summary => new StoryIntelligenceGlobalProgressBookViewModel
{
ProjectID = summary.ProjectID,
BookID = summary.BookID,
BookTitle = string.IsNullOrWhiteSpace(summary.BookDisplayTitle) ? "Book" : summary.BookDisplayTitle,
RunCount = summary.RunCount,
PendingRunCount = summary.PendingRunCount,
RunningRunCount = summary.RunningRunCount,
CompletedRunCount = summary.CompletedRunCount,
FailedRunCount = summary.FailedRunCount,
TotalExpectedScenes = summary.TotalExpectedScenes,
CompletedScenes = summary.CompletedScenes,
FailedScenes = summary.FailedScenes,
ActiveScenes = summary.ActiveScenes,
Status = summary.Status,
TotalDurationMs = summary.TotalDurationMs,
UpdatedUtc = summary.UpdatedUtc
})
.ToList()
};
}
}

View File

@ -154,6 +154,37 @@ 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>();
@ -281,7 +312,9 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "passenger seat")) return "PassengerSeat"; if (ContainsAny(text, "passenger seat")) return "PassengerSeat";
if (ContainsAny(text, "rear seat", "back seat")) return "RearSeat"; if (ContainsAny(text, "rear seat", "back seat")) return "RearSeat";
if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "boot", "cabin", "dashboard")) return "VehicleInterior"; if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "boot", "cabin", "dashboard")) return "VehicleInterior";
if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom"; if (ContainsAny(text, "ladies toilet", "ladies toilets", "gents toilet", "gents toilets", "public toilet", "public toilets", "pub toilet", "pub toilets", "bar toilet", "bar toilets", "toilet cubicle", "toilet cubicles", "washroom in the pub", "washroom in the bar")) return "PublicToilet";
if (ContainsAny(text, "bathroom")) return "Bathroom";
if (ContainsAny(text, "toilet", "washroom")) return "PublicToilet";
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen"; if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom"; if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom"; if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
@ -321,7 +354,9 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "passenger seat")) return "PassengerSeat"; if (ContainsAny(text, "passenger seat")) return "PassengerSeat";
if (ContainsAny(text, "rear seat", "back seat")) return "RearSeat"; if (ContainsAny(text, "rear seat", "back seat")) return "RearSeat";
if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "boot", "cabin", "dashboard")) return "VehicleInterior"; if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "boot", "cabin", "dashboard")) return "VehicleInterior";
if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom"; if (ContainsAny(text, "ladies toilet", "ladies toilets", "gents toilet", "gents toilets", "public toilet", "public toilets", "pub toilet", "pub toilets", "bar toilet", "bar toilets", "toilet cubicle", "toilet cubicles", "washroom in the pub", "washroom in the bar")) return "PublicToilet";
if (ContainsAny(text, "bathroom")) return "Bathroom";
if (ContainsAny(text, "toilet", "washroom")) return "PublicToilet";
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen"; if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom"; if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom"; if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
@ -429,7 +464,6 @@ public static class StoryIntelligenceIllustrationCompatibility
public static bool LocationCompatible(string requestedType, string candidateType) public static bool LocationCompatible(string requestedType, string candidateType)
=> requestedType == candidateType => requestedType == candidateType
|| (requestedType is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" && candidateType is "Road" or "Lane" or "Street")
|| (requestedType is "DriverSeat" or "PassengerSeat" or "RearSeat" && candidateType == "VehicleInterior") || (requestedType is "DriverSeat" or "PassengerSeat" or "RearSeat" && candidateType == "VehicleInterior")
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior") || (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior") || (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior")
@ -442,6 +476,19 @@ 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();
@ -651,6 +698,9 @@ 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;
@ -818,3 +868,8 @@ 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);

View File

@ -29,21 +29,6 @@ public sealed class StoryIntelligenceImportCommitService(
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
private static readonly IReadOnlyDictionary<string, string[]> MetricAliases = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["action"] = ["Action"],
["emotion"] = ["Emotion", "Emotional Weight", "Emotional Resonance"],
["tension"] = ["Tension"],
["conflict"] = ["Conflict", "Relationship Conflict"],
["mystery"] = ["Mystery"],
["romance"] = ["Romance", "Emotional Intimacy"],
["humour"] = ["Humour", "Humor", "Comedy", "Comic Relief"],
["humor"] = ["Humour", "Humor", "Comedy", "Comic Relief"],
["revelation"] = ["Revelation", "Reveal"],
["pacing"] = ["Pacing", "PacingIntensity", "Pacing Intensity", "Overall Intensity"],
["pacingIntensity"] = ["Pacing", "PacingIntensity", "Pacing Intensity", "Overall Intensity"]
};
public async Task<StoryIntelligenceImportConfirmationViewModel?> BuildConfirmationAsync(int runId) public async Task<StoryIntelligenceImportConfirmationViewModel?> BuildConfirmationAsync(int runId)
{ {
var prepared = await PrepareAsync(runId); var prepared = await PrepareAsync(runId);
@ -134,22 +119,19 @@ 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).ToList() ? (await metricTypes.ListForManagementAsync(run.ProjectID.Value)).Where(metric => metric.IsActive && metric.IsEnabledForProject).ToList()
: []; : [];
var noteTypes = await writerWorkspace.ListNoteTypesAsync(); var noteTypes = await writerWorkspace.ListNoteTypesAsync();
var characterMap = run.ProjectID.HasValue var characterMap = run.ProjectID.HasValue
@ -172,13 +154,18 @@ 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); var fallback = BuildFallbackImportScene(sceneResult, metricMap, canonicalScene);
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.");
@ -190,12 +177,20 @@ public sealed class StoryIntelligenceImportCommitService(
continue; continue;
} }
importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap)); importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap, canonicalScene));
} }
if (importScenes.Count == 0) if (importScenes.Count == 0)
{ {
blockers.Add("No scenes are ready to create for this chapter."); blockers.Add("No analysed scene results are available 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";
@ -218,12 +213,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 = importScenes.Count, ScenesToCreate = canonicalScenes.Count > 0 ? canonicalScenes.Count : 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 = importScenes Scenes = BuildReviewScenes(canonicalScenes, sceneResults, importScenes)
}, },
importScenes, importScenes,
timeModeId, timeModeId,
@ -239,7 +234,8 @@ 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)
@ -247,12 +243,13 @@ public sealed class StoryIntelligenceImportCommitService(
&& characterMap.TryGetValue(povName, out var characterId) && characterMap.TryGetValue(povName, out var characterId)
? characterId ? characterId
: (int?)null; : (int?)null;
var summary = Clean(parsed.Summary?.Short); var summary = CanonicalSummary(canonicalScene) ?? 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,
@ -261,24 +258,28 @@ 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 = sceneTitle, SceneTitle = canonicalScene is not null && !string.IsNullOrWhiteSpace(canonicalScene.SceneTitle) ? canonicalScene.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 = string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? null : Clean(parsed.Summary.Detailed), OutcomeNotes = canonicalScene is null && !string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? Clean(parsed.Summary.Detailed) : null,
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 = ExtractSummaryFromJsonText(sceneResult.OutputTextJson) var summary = CanonicalSummary(canonicalScene)
?? 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
@ -308,6 +309,7 @@ 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,
@ -316,14 +318,82 @@ public sealed class StoryIntelligenceImportCommitService(
PovCharacterID = null, PovCharacterID = null,
PurposeTypeIDs = [], PurposeTypeIDs = [],
Metrics = [], Metrics = [],
SceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary), SceneTitle = canonicalScene is not null && !string.IsNullOrWhiteSpace(canonicalScene.SceneTitle) ? canonicalScene.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);
@ -349,54 +419,11 @@ public sealed class StoryIntelligenceImportCommitService(
} }
} }
private static Dictionary<string, SceneMetricType> BuildMetricMap(IReadOnlyList<SceneMetricType> metricTypes) private static IReadOnlyDictionary<string, SceneMetricType> BuildMetricMap(IReadOnlyList<SceneMetricType> metricTypes)
{ => StoryIntelligenceMaterialisationService.BuildMetricMap(metricTypes);
var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase);
var map = new Dictionary<string, SceneMetricType>(StringComparer.OrdinalIgnoreCase);
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);
if (metric is not null)
{
map[schemaName] = metric;
}
}
return map;
}
private static IReadOnlyList<SceneMetricValue> BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, SceneMetricType> metricMap) private static IReadOnlyList<SceneMetricValue> BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, SceneMetricType> metricMap)
{ => StoryIntelligenceMaterialisationService.BuildMetrics(parsed, metricMap);
if (parsed.Metrics is null || parsed.Metrics.Count == 0)
{
return [];
}
var values = new List<SceneMetricValue>();
var importedMetricTypeIds = new HashSet<int>();
foreach (var (name, metric) in parsed.Metrics)
{
if (!metric.Score.HasValue || !metricMap.TryGetValue(name, out var metricType))
{
continue;
}
if (!importedMetricTypeIds.Add(metricType.MetricTypeID))
{
continue;
}
values.Add(new SceneMetricValue
{
MetricTypeID = metricType.MetricTypeID,
MetricName = metricType.MetricName,
Value = Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue),
Notes = metric.Confidence.HasValue ? $"Story Intelligence confidence {metric.Confidence.Value:0.##}." : "Imported from Story Intelligence."
});
}
return values;
}
private static IReadOnlyList<int> MatchPurposes(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, int> purposeMap) private static IReadOnlyList<int> MatchPurposes(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, int> purposeMap)
{ {
@ -855,6 +882,17 @@ 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() + "...";

View File

@ -16,7 +16,8 @@ public static partial class StoryIntelligenceJsonRepair
} }
warnings.AddRange(extraction.Warnings); warnings.AddRange(extraction.Warnings);
var numericRepaired = RepairTrailingDecimalNumbers(extraction.Json, warnings); var numericWhitespaceRepaired = RepairWhitespaceInDecimalNumbers(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))
@ -133,6 +134,162 @@ 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);
@ -240,7 +397,11 @@ public static class StoryIntelligenceAiJsonParser
var repair = StoryIntelligenceJsonRepair.Repair(rawAssistantOutput); var repair = StoryIntelligenceJsonRepair.Repair(rawAssistantOutput);
if (!repair.Success) if (!repair.Success)
{ {
return StoryIntelligenceAiJsonParseResult<T>.Failed(extraction.Json, repair.ErrorMessage ?? strictEx.Message); return StoryIntelligenceAiJsonParseResult<T>.Failed(
extraction.Json,
repair.ErrorMessage ?? strictEx.Message,
strictEx.Message,
strictEx.Path);
} }
try try
@ -251,11 +412,13 @@ 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); return StoryIntelligenceAiJsonParseResult<T>.Failed(extraction.Json, repairedEx.Message, strictEx.Message, strictEx.Path);
} }
} }
} }
@ -265,7 +428,8 @@ 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."
@ -310,15 +474,23 @@ 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,
=> new(true, rawJson, repairedJson, parsed, warnings, null); string? initialErrorMessage = null,
string? initialErrorPath = null)
=> new(true, rawJson, repairedJson, parsed, warnings, null, initialErrorMessage, initialErrorPath);
public static StoryIntelligenceAiJsonParseResult<T> Failed(string rawJson, string errorMessage) public static StoryIntelligenceAiJsonParseResult<T> Failed(
=> new(false, rawJson, null, default, [], errorMessage); string rawJson,
string errorMessage,
string? initialErrorMessage = null,
string? initialErrorPath = null)
=> new(false, rawJson, null, default, [], errorMessage, initialErrorMessage, initialErrorPath);
} }

View File

@ -29,6 +29,19 @@ public sealed class StoryIntelligenceKnowledgeImportService(
public async Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) public async Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{ {
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
if (batch.KnowledgeStageComplete || pipeline?.KnowledgeReviewCompletedUtc is not null)
{
return new StoryIntelligenceKnowledgeReviewViewModel
{
HasCommittedScenes = true,
CanImport = false,
IsComplete = true,
KnowledgeStateOptions = [],
Candidates = []
};
}
var lookupData = await characters.GetLookupsAsync(batch.ProjectID); var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates); var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
@ -70,7 +83,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates); var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
if (!data.HasCommittedScenes) if (!data.HasCommittedScenes)
{ {
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing knowledge." }; return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing knowledge." };
} }
var choices = form.Knowledge var choices = form.Knowledge
@ -79,15 +92,23 @@ public sealed class StoryIntelligenceKnowledgeImportService(
SaveKnowledgeSelections(batch, choices.Values); SaveKnowledgeSelections(batch, choices.Values);
if (choices.Count == 0) if (choices.Count == 0)
{ {
if (data.Candidates.Count == 0) var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var hasUndecidedCandidates = data.Candidates.Any(candidate => !decidedKeys.Contains(candidate.Key));
if (!hasUndecidedCandidates)
{ {
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 were waiting." Message = "Knowledge review completed. No knowledge decisions are waiting."
}; };
} }
@ -219,21 +240,28 @@ public sealed class StoryIntelligenceKnowledgeImportService(
foreach (var item in batch.Items) foreach (var item in batch.Items)
{ {
var commit = await runs.GetImportCommitAsync(item.RunID); var run = await runs.GetRunAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{ {
continue; continue;
} }
hasCommittedScenes = true; hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
.Where(scene => scene.ImportRunID == item.RunID) var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
.ToList(); ? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).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);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults) foreach (var sceneResult in sceneResults)
{ {
@ -243,7 +271,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
continue; continue;
} }
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
if (importedScene is null) if (importedScene is null)
{ {
continue; continue;
@ -315,19 +343,20 @@ public sealed class StoryIntelligenceKnowledgeImportService(
decimal? confidence, decimal? confidence,
IReadOnlyList<KnowledgeState> knowledgeStates) IReadOnlyList<KnowledgeState> knowledgeStates)
{ {
var character = characterIndex.Find(characterName); var resolvedCharacterName = ResolveKnowledgeRecipientName(characterName, importedScene);
var character = characterIndex.Find(resolvedCharacterName);
var statement = CleanKnowledgeStatement(knowledgeItem); var statement = CleanKnowledgeStatement(knowledgeItem);
if (character is null || string.IsNullOrWhiteSpace(statement)) if (character is null || string.IsNullOrWhiteSpace(statement))
{ {
return; return;
} }
var key = KnowledgeKey(character.CharacterID, statement); var stateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates);
var key = KnowledgeKey(character.CharacterID, statement, stateName);
if (!groups.TryGetValue(key, out var candidate)) if (!groups.TryGetValue(key, out var candidate))
{ {
var existingKey = ExistingKnowledgeKey(character.CharacterID, statement); var existingKey = ExistingKnowledgeKey(character.CharacterID, statement, stateName);
existingKnowledge.TryGetValue(existingKey, out var existingMatch); existingKnowledge.TryGetValue(existingKey, out var existingMatch);
var stateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates);
var assetMatch = assetIndex.Find(statement); var assetMatch = assetIndex.Find(statement);
candidate = new KnowledgeCandidate( candidate = new KnowledgeCandidate(
key, key,
@ -366,7 +395,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
{ {
foreach (var item in await characters.ListKnowledgeByCharacterAsync(character.CharacterID)) foreach (var item in await characters.ListKnowledgeByCharacterAsync(character.CharacterID))
{ {
var key = ExistingKnowledgeKey(item.CharacterID, KnowledgeTitle(item)); var key = ExistingKnowledgeKey(item.CharacterID, KnowledgeTitle(item), item.KnowledgeStateName);
if (!rows.ContainsKey(key)) if (!rows.ContainsKey(key))
{ {
rows[key] = item; rows[key] = item;
@ -410,9 +439,15 @@ 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))
@ -467,6 +502,25 @@ public sealed class StoryIntelligenceKnowledgeImportService(
&& !string.IsNullOrWhiteSpace(FirstConfigured(observation.Description, observation.Predicate, observation.ObjectName)); && !string.IsNullOrWhiteSpace(FirstConfigured(observation.Description, observation.Predicate, observation.ObjectName));
} }
private static string ResolveKnowledgeRecipientName(string? characterName, Scene scene)
{
var clean = Clean(characterName);
if (string.IsNullOrWhiteSpace(clean))
{
return clean;
}
if (string.Equals(clean, "Narrator", StringComparison.OrdinalIgnoreCase)
|| string.Equals(clean, "I", StringComparison.OrdinalIgnoreCase)
|| string.Equals(clean, "me", StringComparison.OrdinalIgnoreCase)
|| string.Equals(clean, "myself", StringComparison.OrdinalIgnoreCase))
{
return Clean(scene.POVCharacterName ?? scene.POVSuggestedName);
}
return clean;
}
private static string KnowledgeStateFromSignal(string? changeType, string? statement, IReadOnlyList<KnowledgeState> states) private static string KnowledgeStateFromSignal(string? changeType, string? statement, IReadOnlyList<KnowledgeState> states)
{ {
var value = NormaliseForMatching($"{changeType} {statement}"); var value = NormaliseForMatching($"{changeType} {statement}");
@ -568,15 +622,41 @@ public sealed class StoryIntelligenceKnowledgeImportService(
return firstLine ?? cleaned; return firstLine ?? cleaned;
} }
private static string KnowledgeKey(int characterId, string statement) private static string KnowledgeKey(int characterId, string statement, string? knowledgeStateName)
=> $"{characterId}:{CanonicalKnowledgeKey(statement)}"; => $"{characterId}:{CanonicalKnowledgeStateKey(knowledgeStateName)}:{CanonicalKnowledgeKey(statement)}";
private static string ExistingKnowledgeKey(int characterId, string statement) private static string ExistingKnowledgeKey(int characterId, string statement, string? knowledgeStateName)
=> KnowledgeKey(characterId, statement); => KnowledgeKey(characterId, statement, knowledgeStateName);
private static string CanonicalKnowledgeStateKey(string? value)
{
var normalised = Normalise(value);
if (normalised.Contains("suspect"))
{
return "suspects";
}
if (normalised.Contains("misunderstand"))
{
return "misunderstands";
}
if (normalised.Contains("unaware"))
{
return "unaware";
}
if (normalised.Contains("partial") || normalised.Contains("believe"))
{
return "partial";
}
return "knows";
}
private static string CanonicalKnowledgeKey(string? value) private static string CanonicalKnowledgeKey(string? value)
{ {
var normalised = NormaliseForMatching(value); var normalised = NormaliseForMatching(value).Trim();
var possessiveFamily = Regex.Match( var possessiveFamily = Regex.Match(
normalised, normalised,
@"\b(?<subject>\w+)\s+(?:is|was)\s+(?<holder>\w+)\s+s\s+(?:biological\s+)?(?<relation>father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b", @"\b(?<subject>\w+)\s+(?:is|was)\s+(?<holder>\w+)\s+s\s+(?:biological\s+)?(?<relation>father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b",
@ -595,20 +675,25 @@ public sealed class StoryIntelligenceKnowledgeImportService(
return $"{pronounFamily.Groups["relation"].Value} {pronounFamily.Groups["subject"].Value} {pronounFamily.Groups["holder"].Value}"; return $"{pronounFamily.Groups["relation"].Value} {pronounFamily.Groups["subject"].Value} {pronounFamily.Groups["holder"].Value}";
} }
normalised = Regex.Replace(
normalised,
@"^\w+\s+(?:learns?|discovers?|realises?|realizes?|knows?|believes?|suspects?|confirms?|is\s+told)\s+",
string.Empty,
RegexOptions.IgnoreCase);
normalised = Regex.Replace(normalised, @"\b(discovers?|learns?|realises?|realizes?|knows?|believes?|suspects?|confirms?|is told|that|the|a|an|his|her|their|its|about)\b", " ", RegexOptions.IgnoreCase); normalised = Regex.Replace(normalised, @"\b(discovers?|learns?|realises?|realizes?|knows?|believes?|suspects?|confirms?|is told|that|the|a|an|his|her|their|its|about)\b", " ", RegexOptions.IgnoreCase);
return Whitespace.Replace(normalised, " ").Trim(); return Whitespace.Replace(normalised, " ").Trim();
} }
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed) private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{ {
var summary = Clean(parsed.Summary?.Short); var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, 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)
=> $"Scene {scene.SceneNumber:g}"; => $"Chapter {scene.ChapterNumber: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);

View File

@ -31,7 +31,8 @@ public sealed class StoryIntelligenceLocationImportService(
"bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside", "bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside",
"inside", "nearby", "somewhere", "home", "house", "flat", "streets", "main road", "dual carriageway", "inside", "nearby", "somewhere", "home", "house", "flat", "streets", "main road", "dual carriageway",
"flyover", "motorway roundabout", "under the tree", "under-flyover / link road", "sitting room", "ring road", "flyover", "motorway roundabout", "under the tree", "under-flyover / link road", "sitting room", "ring road",
"entrance", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "garden"); "entrance", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "garden",
"tunnel");
private static readonly HashSet<string> ThrowawayLocationFragments = CreateSet( private static readonly HashSet<string> ThrowawayLocationFragments = CreateSet(
"door", "porch", "exterior", "tree", "pavement"); "door", "porch", "exterior", "tree", "pavement");
@ -43,21 +44,43 @@ public sealed class StoryIntelligenceLocationImportService(
private static readonly HashSet<string> RepeatableGenericSettings = CreateSet( private static readonly HashSet<string> RepeatableGenericSettings = CreateSet(
"kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage",
"seating area", "waiting room", "interview room", "staff room"); "seating area", "waiting room", "interview room", "staff room", "car park", "parking lot", "tunnel");
private static readonly HashSet<string> SingularisableLocationWords = CreateSet(
"garage", "road", "street", "room", "house", "flat", "bedroom", "kitchen", "garden", "office");
private static readonly HashSet<string> StoryLocationSignals = CreateSet( private static readonly HashSet<string> StoryLocationSignals = CreateSet(
"trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub", "trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub",
"cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate", "doweries"); "cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate", "inn", "theatre",
"cinema", "library", "museum", "club", "restaurant", "bar");
private static readonly HashSet<string> ConjunctionLocationWords = CreateSet( private static readonly HashSet<string> ConjunctionLocationWords = CreateSet(
"shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden", "shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden",
"rear garden", "front", "rear", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street", "car park"); "rear garden", "front", "rear", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street",
"car park", "flat", "home");
private static readonly Regex AddressPattern = new(@"^\d+\s+\p{L}", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex AddressPattern = new(@"^\d+\s+\p{L}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex NamedRoadPattern = new(@"\b(street|road|lane|avenue|drive|way|close|crescent|square|place)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex NamedRoadPattern = new(@"\b(street|road|lane|avenue|drive|way|close|crescent|square|place)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ControlCharacters = new(@"[\u0000-\u001F\u007F]+", RegexOptions.Compiled);
private static readonly Regex PossessiveControlCharacter = new(@"(?<=\p{L})[\u0000-\u001F\u007F]+(?=s\b)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex LeadingContextPhrase = new(@"^(?:inside|outside|at|in|within|near|around|beside|by)\s+(?:the\s+|a\s+|an\s+)?", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex TemporalSuffix = new(@",\s*(?:(?:early|late|later|same|next|following)\s+)?(?:morning|afternoon|evening|night|day|hours?|dawn|dusk|midday|midnight|christmas|boxing day|weekend|weekday|summer|winter|spring|autumn|fall|today|tonight|later)\b.*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled);
public async Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) public async Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{ {
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
if (batch.LocationStageComplete || pipeline?.LocationReviewCompletedUtc is not null)
{
return new StoryIntelligenceLocationReviewViewModel
{
HasCommittedScenes = true,
CanImport = false,
IsComplete = true,
Candidates = []
};
}
var data = await BuildCandidateDataAsync(batch); var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var decidedKeys = batch.LocationDecisions.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();
@ -79,7 +102,24 @@ public sealed class StoryIntelligenceLocationImportService(
ExampleFirstAppearance = candidate.FirstAppearanceNote, ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExampleContext = candidate.Description, ExampleContext = candidate.Description,
ExistingLocationID = candidate.ExistingLocationID, ExistingLocationID = candidate.ExistingLocationID,
ExistingLocationName = candidate.ExistingLocationName ExistingLocationName = candidate.ExistingLocationName,
EvidenceSummaries = candidate.Appearances
.GroupBy(appearance => appearance.SceneID)
.OrderBy(group => group.Min(appearance => appearance.SceneNumber))
.Take(8)
.Select(group =>
{
var first = group.First();
var observed = string.Join(", ", group
.Select(appearance => appearance.ObservedName)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3));
return string.IsNullOrWhiteSpace(observed)
? first.SceneReference
: $"{first.SceneReference}: {observed}";
})
.ToList()
}).ToList() }).ToList()
}; };
} }
@ -89,7 +129,7 @@ public sealed class StoryIntelligenceLocationImportService(
var data = await BuildCandidateDataAsync(batch); var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes) if (!data.HasCommittedScenes)
{ {
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing locations." }; return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing locations." };
} }
var choices = form.Locations var choices = form.Locations
@ -266,21 +306,28 @@ public sealed class StoryIntelligenceLocationImportService(
foreach (var item in batch.Items) foreach (var item in batch.Items)
{ {
var commit = await runs.GetImportCommitAsync(item.RunID); var run = await runs.GetRunAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) if (run?.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
continue;
}
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
if (sceneResults.Count == 0)
{ {
continue; continue;
} }
hasCommittedScenes = true; hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
.Where(scene => scene.ImportRunID == item.RunID) var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
.ToList(); ? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).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);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults) foreach (var sceneResult in sceneResults)
{ {
@ -290,7 +337,7 @@ public sealed class StoryIntelligenceLocationImportService(
continue; continue;
} }
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
if (importedScene is null) if (importedScene is null)
{ {
continue; continue;
@ -322,25 +369,31 @@ public sealed class StoryIntelligenceLocationImportService(
Scene importedScene, Scene importedScene,
SceneIntelligenceScene parsed) SceneIntelligenceScene parsed)
{ {
var structuralSetting = Clean(importedScene.StructuralSetting);
if (!string.IsNullOrWhiteSpace(structuralSetting))
{
AddLocation(groups, existingIndex, importedScene, parsed, structuralSetting, "named", null, null, true, false, true, 0.95m, "Structural setting from Core Import.");
}
var setting = parsed.Setting; var setting = parsed.Setting;
var settingName = FirstConfigured(setting?.LocationName, setting?.GenericRoomType); var settingName = ResolveContextualLocationName(FirstConfigured(setting?.LocationName, setting?.GenericRoomType), setting?.GenericRoomType, importedScene);
AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, true, setting?.Confidence, "Scene setting"); AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, true, setting?.Confidence, "Scene setting");
foreach (var location in parsed.Locations ?? []) foreach (var location in parsed.Locations ?? [])
{ {
AddLocation(groups, existingIndex, importedScene, parsed, location.Name, location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes); AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(location.Name, location.GenericRoomType, importedScene), location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes);
} }
foreach (var observation in parsed.Observations ?? []) foreach (var observation in parsed.Observations ?? [])
{ {
if (IsLocationEntity(observation.SubjectEntityType)) if (IsLocationEntity(observation.SubjectEntityType))
{ {
AddLocation(groups, existingIndex, importedScene, parsed, observation.SubjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.SubjectName, null, importedScene), null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description);
} }
if (IsLocationEntity(observation.ObjectEntityType)) if (IsLocationEntity(observation.ObjectEntityType))
{ {
AddLocation(groups, existingIndex, importedScene, parsed, observation.ObjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.ObjectName, null, importedScene), null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description);
} }
} }
} }
@ -366,36 +419,58 @@ public sealed class StoryIntelligenceLocationImportService(
return; return;
} }
var key = ResolveCandidateKey(groups, existingIndex, cleanName); var identity = ResolveLocationIdentity(cleanName, existingIndex);
if (identity is null || !IsLocationNameCandidate(identity.DisplayName))
{
return;
}
var key = ResolveCandidateKey(groups, identity.Key, identity.DisplayName);
if (!groups.TryGetValue(key, out var candidate)) if (!groups.TryGetValue(key, out var candidate))
{ {
var match = existingIndex.Find(cleanName); var match = identity.ExistingMatch;
candidate = new LocationCandidate(key, cleanName, match?.LocationID, match?.LocationName) candidate = new LocationCandidate(key, identity.DisplayName, match?.LocationID, match?.LocationName)
{ {
Category = Categorise(cleanName, locationType, genericRoomType), Category = Categorise(identity.DisplayName, locationType, genericRoomType),
ParentLocationHint = Clean(parentLocationHint) ParentLocationHint = Clean(parentLocationHint)
}; };
groups[key] = candidate; groups[key] = candidate;
} }
else if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase))
if (!string.Equals(candidate.DisplayName, identity.DisplayName, StringComparison.OrdinalIgnoreCase))
{
candidate.DisplayName = BestDisplayName(candidate.DisplayName, identity.DisplayName);
candidate.Aliases.Add(identity.DisplayName);
}
if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(identity.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase))
{ {
candidate.Aliases.Add(cleanName); candidate.Aliases.Add(cleanName);
} }
candidate.Confidence = Max(candidate.Confidence, confidence); candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.Description ??= Clean(notes); candidate.Description ??= Clean(notes);
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName); candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, identity.DisplayName);
candidate.ParentLocationHint ??= Clean(parentLocationHint); candidate.ParentLocationHint ??= Clean(parentLocationHint);
candidate.Appearances.Add(new LocationAppearanceImport( var appearance = new LocationAppearanceImport(
importedScene.SceneID, importedScene.SceneID,
importedScene.SceneNumber, importedScene.SceneNumber,
importedScene.SceneTitle, importedScene.SceneTitle,
BuildSceneReference(importedScene),
cleanName,
presentInScene && !mentionedOnly, presentInScene && !mentionedOnly,
mentionedOnly, mentionedOnly,
isSceneSetting, isSceneSetting,
notes, notes,
confidence, confidence,
importedScene.PrimaryLocationID.HasValue && candidate.ExistingLocationID == importedScene.PrimaryLocationID)); importedScene.PrimaryLocationID.HasValue && candidate.ExistingLocationID == importedScene.PrimaryLocationID);
if (!candidate.Appearances.Any(existing => existing.SceneID == appearance.SceneID
&& string.Equals(existing.ObservedName, appearance.ObservedName, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Clean(existing.Notes), Clean(appearance.Notes), StringComparison.OrdinalIgnoreCase)))
{
candidate.Appearances.Add(appearance);
}
} }
private async Task<LocationIndex> BuildLocationIndexAsync(int projectId) private async Task<LocationIndex> BuildLocationIndexAsync(int projectId)
@ -434,9 +509,15 @@ 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))
@ -555,21 +636,25 @@ public sealed class StoryIntelligenceLocationImportService(
{ {
var normalised = Normalise(name); var normalised = Normalise(name);
var withoutParentheses = Normalise(Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty)); var withoutParentheses = Normalise(Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty));
var withoutArticle = Normalise(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name));
return GenericLocations.Contains(normalised) return GenericLocations.Contains(normalised)
|| GenericLocations.Contains(withoutParentheses) || GenericLocations.Contains(withoutParentheses)
|| GenericLocations.Contains(withoutArticle)
|| InternalLocationWords.Contains(normalised) || InternalLocationWords.Contains(normalised)
|| InternalLocationWords.Contains(withoutParentheses); || InternalLocationWords.Contains(withoutParentheses)
|| InternalLocationWords.Contains(withoutArticle);
} }
private static bool IsGenericComposite(string name) private static bool IsGenericComposite(string name)
{ {
var clean = Clean(name);
var value = Normalise(name); var value = Normalise(name);
if (!value.Contains('/') && !value.Contains(" / ")) return clean.Contains('/')
{ || value.Contains(" then ", StringComparison.OrdinalIgnoreCase)
return false; || value.Contains(" onto ", StringComparison.OrdinalIgnoreCase)
} || value.Contains(" to ", StringComparison.OrdinalIgnoreCase)
|| value.Contains(" from ", StringComparison.OrdinalIgnoreCase)
return true; || IsMultiLocationConjunction(clean);
} }
private static bool ContainsThrowawayFragment(string name) private static bool ContainsThrowawayFragment(string name)
@ -580,13 +665,14 @@ public sealed class StoryIntelligenceLocationImportService(
private static bool IsMergedLocationPhrase(string name) private static bool IsMergedLocationPhrase(string name)
{ {
var value = Normalise(StripParenthetical(name)); var clean = StripParenthetical(name);
var value = Normalise(clean);
if (!value.Contains(" and ", StringComparison.OrdinalIgnoreCase)) if (!value.Contains(" and ", StringComparison.OrdinalIgnoreCase))
{ {
return false; return false;
} }
if (value is "rose and crown") if (LooksLikeNamedCompound(clean))
{ {
return false; return false;
} }
@ -598,6 +684,41 @@ public sealed class StoryIntelligenceLocationImportService(
private static bool ContainsConjunctionLocationWord(string value) private static bool ContainsConjunctionLocationWord(string value)
=> ConjunctionLocationWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase)); => ConjunctionLocationWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
private static bool LooksLikeNamedCompound(string value)
{
var clean = Clean(value);
if (!clean.Contains(" and ", StringComparison.OrdinalIgnoreCase) || !clean.Any(char.IsUpper))
{
return false;
}
var parts = clean.Split(" and ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length == 2
&& parts.All(part => part.Length > 1 && part.Any(char.IsUpper))
&& parts.All(part => !ConjunctionLocationWords.Contains(Normalise(part)));
}
private static bool IsMultiLocationConjunction(string value)
{
var clean = Clean(value);
if (!clean.Contains(" and ", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var parts = clean.Split(" and ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length > 1
&& parts.Count(part => IsNamedLocation(StripLeadingContextPhrases(part))) > 1
&& parts.Any(part => ContainsConjunctionLocationWord(Normalise(part)));
}
private static bool IsBarePossessiveReference(string value)
{
var words = Normalise(value).Split(' ', StringSplitOptions.RemoveEmptyEntries);
return words.Length <= 2
&& Regex.IsMatch(Clean(value), @"'\s*s$", RegexOptions.IgnoreCase);
}
private static bool HasUsefulParentHint(string? parentHint) private static bool HasUsefulParentHint(string? parentHint)
{ {
var clean = Clean(parentHint); var clean = Clean(parentHint);
@ -616,6 +737,11 @@ public sealed class StoryIntelligenceLocationImportService(
return false; return false;
} }
if (IsBarePossessiveReference(clean))
{
return false;
}
if (AddressPattern.IsMatch(clean) || IsNamedRoad(clean)) if (AddressPattern.IsMatch(clean) || IsNamedRoad(clean))
{ {
return true; return true;
@ -630,6 +756,11 @@ public sealed class StoryIntelligenceLocationImportService(
private static bool IsNamedLocation(string name) private static bool IsNamedLocation(string name)
{ {
var withoutParentheses = StripParenthetical(name); var withoutParentheses = StripParenthetical(name);
if (IsBarePossessiveReference(withoutParentheses))
{
return false;
}
return AddressPattern.IsMatch(withoutParentheses) return AddressPattern.IsMatch(withoutParentheses)
|| IsNamedRoad(name) || IsNamedRoad(name)
|| withoutParentheses.Any(char.IsUpper) || withoutParentheses.Any(char.IsUpper)
@ -683,27 +814,6 @@ public sealed class StoryIntelligenceLocationImportService(
|| value.Contains("occursAt", StringComparison.OrdinalIgnoreCase); || value.Contains("occursAt", StringComparison.OrdinalIgnoreCase);
} }
private static string ResolveCandidateKey(
IReadOnlyDictionary<string, LocationCandidate> groups,
LocationIndex existingIndex,
string name)
{
var existingMatch = existingIndex.Find(name);
if (existingMatch is not null)
{
var existingKey = CanonicalLocationKey(existingMatch.LocationName);
if (groups.ContainsKey(existingKey))
{
return existingKey;
}
return existingKey;
}
var canonical = CanonicalLocationKey(name);
return groups.ContainsKey(canonical) ? canonical : canonical;
}
private static void AddResolvedName(Dictionary<string, int> map, string name, int locationId) private static void AddResolvedName(Dictionary<string, int> map, string name, int locationId)
{ {
var clean = Clean(name); var clean = Clean(name);
@ -728,12 +838,15 @@ 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(parsed.Summary?.Short); var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
return string.IsNullOrWhiteSpace(summary) return string.IsNullOrWhiteSpace(summary)
? $"Scene {scene.SceneNumber:g}: {locationName}" ? $"{BuildSceneReference(scene)}: {locationName}"
: $"Scene {scene.SceneNumber:g}: {summary}"; : $"{BuildSceneReference(scene)}: {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;
@ -751,34 +864,269 @@ public sealed class StoryIntelligenceLocationImportService(
private static string CanonicalLocationKey(string name) private static string CanonicalLocationKey(string name)
{ {
var clean = StripParenthetical(CleanLocationName(name)); var clean = StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(NormaliseLocationIdentityName(name));
if (clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase)) return StoryIntelligenceEntityTextNormaliser.SingulariseSafeFinalWord(clean, SingularisableLocationWords);
{
clean = clean[4..];
} }
return Normalise(clean); private static LocationIdentity? ResolveLocationIdentity(string name, LocationIndex existingIndex)
{
var clean = CleanLocationName(name);
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
var existingMatch = existingIndex.Find(clean);
if (existingMatch is not null)
{
return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch);
}
var identityName = NormaliseLocationIdentityName(clean);
existingMatch = existingIndex.Find(identityName);
if (existingMatch is not null)
{
return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch);
}
return new LocationIdentity(CanonicalLocationKey(identityName), identityName, null);
}
private static string NormaliseLocationIdentityName(string? value)
{
var clean = CleanLocationName(value);
clean = PossessiveControlCharacter.Replace(clean, "'");
clean = ControlCharacters.Replace(clean, " ");
clean = CleanLocationName(clean);
clean = StripParenthetical(clean);
clean = StripLeadingContextPhrases(clean);
clean = StripTemporalSuffix(clean);
clean = PreferSingleIdentitySegment(clean);
clean = StripLowValueContextSuffix(clean);
return CleanLocationName(clean);
}
private static string StripLeadingContextPhrases(string value)
{
var clean = Clean(value);
string previous;
do
{
previous = clean;
clean = LeadingContextPhrase.Replace(clean, string.Empty);
} while (!string.Equals(previous, clean, StringComparison.Ordinal));
return Clean(clean);
}
private static string StripTemporalSuffix(string value)
=> Clean(TemporalSuffix.Replace(value, string.Empty));
private static string PreferSingleIdentitySegment(string value)
{
var clean = Clean(value);
if (Regex.IsMatch(clean, @"\s*/\s*|;|\bthen\b", RegexOptions.IgnoreCase))
{
var parts = Regex.Split(clean, @"\s*/\s*|;|\bthen\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)
.Where(part => !string.IsNullOrWhiteSpace(part))
.Select(part => part.Trim())
.ToArray();
var named = parts
.Select(StripLeadingContextPhrases)
.Select(StripTemporalSuffix)
.Select(StripLowValueContextSuffix)
.Where(IsNamedLocation)
.OrderByDescending(LocationNameQualityScore)
.ThenBy(part => part.Length)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(named))
{
return named;
}
}
return Regex.Replace(clean, @"\s+and\s*$", string.Empty, RegexOptions.IgnoreCase).Trim();
}
private static string ResolveCandidateKey(
IReadOnlyDictionary<string, LocationCandidate> groups,
string canonicalKey,
string displayName)
{
if (groups.ContainsKey(canonicalKey))
{
return canonicalKey;
}
if (!IsGenericLocation(displayName))
{
return canonicalKey;
}
var contextual = groups.Keys
.Where(key => key.EndsWith($" {canonicalKey}", StringComparison.OrdinalIgnoreCase))
.ToList();
return contextual.Count == 1 ? contextual[0] : canonicalKey;
}
private static string StripLowValueContextSuffix(string value)
{
var clean = Clean(value);
var patterns = new[]
{
@"\s+interior\b.*$",
@"\s+exterior\b.*$",
@"\s+inside\b.*$",
@"\s+outside\b.*$",
@"\s+main\s+room\b.*$",
@"\s+bar\s+area\b.*$",
@"\s+table\s+and\s+bar\b.*$",
@"\s+dancefloor\s+and\s+bar\s+area\b.*$",
@"\s+dance\s+floor\s+and\s+bar\s+area\b.*$",
@"\s+and\s+driveway\b.*$",
@"\s+and\s+walk\s+to\b.*$",
@"\s+and\s+waiting\s+area\b.*$",
@"\s+en\s+route\b.*$",
@"\s+and\s+outside\b.*$",
@"\s+and\s+inside\b.*$"
};
foreach (var pattern in patterns)
{
var candidate = Regex.Replace(clean, pattern, string.Empty, RegexOptions.IgnoreCase).Trim();
if (!string.Equals(candidate, clean, StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(candidate)
&& IsNamedLocation(candidate))
{
return candidate;
}
}
return clean;
}
private static string BestDisplayName(string current, string next)
{
if (string.IsNullOrWhiteSpace(current))
{
return next;
}
if (string.IsNullOrWhiteSpace(next))
{
return current;
}
var currentScore = LocationNameQualityScore(current);
var nextScore = LocationNameQualityScore(next);
if (nextScore != currentScore)
{
return nextScore > currentScore ? next : current;
}
return next.Length < current.Length ? next : current;
}
private static int LocationNameQualityScore(string value)
{
var clean = Clean(value);
var normalised = Normalise(clean);
var score = 0;
if (clean.Any(char.IsUpper))
{
score += 5;
}
if (IsStoryLocation(clean))
{
score += 4;
}
if (clean.Contains('\'') || clean.Contains('’'))
{
score += 2;
}
if (IsGenericLocation(clean))
{
score -= 3;
}
if (normalised.Contains(" and ", StringComparison.OrdinalIgnoreCase)
|| clean.Contains('/')
|| TemporalSuffix.IsMatch(clean)
|| LeadingContextPhrase.IsMatch(clean))
{
score -= 2;
}
if (normalised.Contains("interior", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains("exterior", StringComparison.OrdinalIgnoreCase))
{
score -= 1;
}
return score;
}
private static string ResolveContextualLocationName(string? name, string? genericRoomType, Scene scene)
{
var clean = CleanLocationName(name);
if (string.IsNullOrWhiteSpace(clean))
{
return clean;
}
var structuralSetting = CleanLocationName(scene.StructuralSetting);
var leaf = CleanLocationName(FirstConfigured(genericRoomType, clean));
if (!string.IsNullOrWhiteSpace(structuralSetting) && StructuralSettingCoversLocation(structuralSetting, leaf))
{
return structuralSetting;
}
var povName = Clean(scene.POVCharacterName ?? scene.POVSuggestedName);
if (!string.IsNullOrWhiteSpace(povName))
{
clean = Regex.Replace(clean, @"\bNarrator's\b", $"{povName}'s", RegexOptions.IgnoreCase);
clean = Regex.Replace(clean, @"\bNarrator’s\b", $"{povName}'s", RegexOptions.IgnoreCase);
if (clean.StartsWith("my ", StringComparison.OrdinalIgnoreCase))
{
return $"{povName}'s {clean[3..].Trim()}";
}
}
return clean;
}
private static bool StructuralSettingCoversLocation(string structuralSetting, string locationName)
{
var settingKey = CanonicalLocationKey(structuralSetting);
var locationKey = CanonicalLocationKey(locationName);
if (string.IsNullOrWhiteSpace(settingKey) || string.IsNullOrWhiteSpace(locationKey))
{
return false;
}
return settingKey.Equals(locationKey, StringComparison.OrdinalIgnoreCase)
|| settingKey.EndsWith($" {locationKey}", StringComparison.OrdinalIgnoreCase);
} }
private static string StripParenthetical(string? value) private static string StripParenthetical(string? value)
=> Regex.Replace(Clean(value), @"\s*\([^)]*\)\s*$", string.Empty); => ParentheticalSuffix.Replace(Clean(value), string.Empty);
private static string CleanLocationName(string? value) private static string CleanLocationName(string? value)
{ {
var clean = Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\''); var clean = StoryIntelligenceEntityTextNormaliser.CleanName(value);
if (clean.EndsWith("'s", StringComparison.OrdinalIgnoreCase) || clean.EndsWith("’s", StringComparison.OrdinalIgnoreCase)) clean = PossessiveControlCharacter.Replace(clean, "'");
{ clean = ControlCharacters.Replace(clean, " ");
return clean; return StoryIntelligenceEntityTextNormaliser.CleanName(clean);
}
return clean;
} }
private static string Normalise(string? value) private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant(); => StoryIntelligenceEntityTextNormaliser.SafeKey(value);
private static string Clean(string? value) private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); => StoryIntelligenceEntityTextNormaliser.Clean(value);
private static HashSet<string> CreateSet(params string[] values) private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase); => values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
@ -805,7 +1153,7 @@ public sealed class StoryIntelligenceLocationImportService(
private sealed class LocationCandidate(string key, string displayName, int? existingLocationId, string? existingLocationName) private sealed class LocationCandidate(string key, string displayName, int? existingLocationId, string? existingLocationName)
{ {
public string Key { get; } = key; public string Key { get; } = key;
public string DisplayName { get; } = displayName; public string DisplayName { get; set; } = displayName;
public int? ExistingLocationID { get; } = existingLocationId; public int? ExistingLocationID { get; } = existingLocationId;
public string? ExistingLocationName { get; } = existingLocationName; public string? ExistingLocationName { get; } = existingLocationName;
public string Category { get; set; } = "Ambiguous location"; public string Category { get; set; } = "Ambiguous location";
@ -821,6 +1169,8 @@ public sealed class StoryIntelligenceLocationImportService(
int SceneID, int SceneID,
decimal SceneNumber, decimal SceneNumber,
string SceneTitle, string SceneTitle,
string SceneReference,
string ObservedName,
bool PresentInScene, bool PresentInScene,
bool MentionedOnly, bool MentionedOnly,
bool IsSceneSetting, bool IsSceneSetting,
@ -828,6 +1178,8 @@ public sealed class StoryIntelligenceLocationImportService(
decimal? Confidence, decimal? Confidence,
bool AlreadyLinked); bool AlreadyLinked);
private sealed record LocationIdentity(string Key, string DisplayName, LocationMatch? ExistingMatch);
private sealed record LocationCandidateData( private sealed record LocationCandidateData(
bool HasCommittedScenes, bool HasCommittedScenes,
int AlreadyLinkedCount, int AlreadyLinkedCount,

View File

@ -0,0 +1,333 @@
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryIntelligenceMaterialisationService
{
Task<StoryIntelligenceMaterialisationResult> MaterialiseReadyBookAsync(int bookId, int userId, CancellationToken cancellationToken = default);
}
public sealed class StoryIntelligenceMaterialisationService(
IStoryIntelligenceResultRepository results,
IStoryIntelligencePipelineRepository pipelines,
ISceneMetricTypeRepository metricTypes,
ISceneRepository scenes,
IChapterRepository chapters,
IStoryIntelligenceNarrativeReviewService narrative,
ILogger<StoryIntelligenceMaterialisationService> logger) : IStoryIntelligenceMaterialisationService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public async Task<StoryIntelligenceMaterialisationResult> MaterialiseReadyBookAsync(int bookId, int userId, CancellationToken cancellationToken = default)
{
var pipeline = await pipelines.GetByBookForUserAsync(bookId, userId);
if (pipeline is null)
{
return StoryIntelligenceMaterialisationResult.NotReady(bookId, "No Story Intelligence pipeline exists for this book and user.");
}
var allRuns = (await results.ListRunsByBookForUserAsync(bookId, userId))
.Where(run => run.ProjectID == pipeline.ProjectID && run.BookID == bookId)
.OrderBy(run => run.CreatedUtc)
.ThenBy(run => run.StoryIntelligenceRunID)
.ToList();
if (allRuns.Count == 0)
{
return StoryIntelligenceMaterialisationResult.NotReady(bookId, "No persisted Story Intelligence runs exist for this book and user.");
}
var lastRun = pipeline.LastRunID.HasValue
? allRuns.FirstOrDefault(run => run.StoryIntelligenceRunID == pipeline.LastRunID.Value)
: null;
lastRun ??= allRuns.Last();
var windowStart = lastRun.CreatedUtc.AddMinutes(-30);
var scopedRuns = allRuns
.Where(run => run.CreatedUtc >= windowStart)
.OrderBy(run => run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(run => run.StoryIntelligenceRunID)
.ToList();
if (scopedRuns.Any(run => run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running))
{
return StoryIntelligenceMaterialisationResult.NotReady(bookId, "Story Intelligence still has active chapter runs.");
}
if (scopedRuns.Any(run => run.Status is StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled))
{
return StoryIntelligenceMaterialisationResult.NotReady(bookId, "Story Intelligence has failed or cancelled chapter runs.");
}
var canonicalScenes = new List<Scene>();
foreach (var chapter in await chapters.ListByBookAsync(bookId))
{
canonicalScenes.AddRange(await scenes.ListByChapterAsync(chapter.ChapterID));
}
var expectedScenes = canonicalScenes.Count(scene => !scene.IsArchived);
var completedScenes = scopedRuns.Sum(run => run.CompletedScenes ?? 0);
var failedScenes = scopedRuns.Sum(run => run.FailedScenes ?? 0);
if (expectedScenes == 0 || completedScenes + failedScenes < expectedScenes)
{
return StoryIntelligenceMaterialisationResult.NotReady(
bookId,
$"Scene results are not complete. Completed/failed {completedScenes + failedScenes:N0} of {expectedScenes:N0} canonical scene(s).");
}
var configuredMetrics = (await metricTypes.ListForManagementAsync(pipeline.ProjectID))
.Where(metric => metric.IsActive && metric.IsEnabledForProject)
.OrderBy(metric => metric.SortOrder)
.ThenBy(metric => metric.MetricName)
.ToList();
var metricMap = BuildMetricMap(configuredMetrics);
var metricValuesSaved = 0;
var sceneResultsRead = 0;
var sceneResultsParsed = 0;
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
foreach (var run in scopedRuns.Where(run => run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
{
cancellationToken.ThrowIfCancellationRequested();
if (run.ChapterID.HasValue)
{
batchItems.Add(new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{bookId}-chapter-{run.ChapterID.Value}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
ChapterTitle = string.Empty,
ChapterID = run.ChapterID.Value,
RunID = run.StoryIntelligenceRunID
});
}
foreach (var sceneResult in await results.ListSceneResultsAsync(run.StoryIntelligenceRunID))
{
cancellationToken.ThrowIfCancellationRequested();
sceneResultsRead++;
if (!sceneResult.SceneID.HasValue || sceneResult.ValidationErrorsCount > 0)
{
continue;
}
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
continue;
}
sceneResultsParsed++;
var metrics = BuildMetrics(parsed, metricMap);
if (metrics.Count == 0)
{
continue;
}
await scenes.SaveMetricValuesAsync(sceneResult.SceneID.Value, metrics);
metricValuesSaved += metrics.Count;
}
}
var narrativeCandidatesBefore = (await narrative.ListCandidatesByBookAsync(bookId)).Count;
var narrativeCandidatesAfter = narrativeCandidatesBefore;
if (batchItems.Count > 0)
{
var batch = new OnboardingStoryIntelligenceBatch
{
UserID = userId,
OnboardingID = 0,
PreviewID = Guid.Empty,
ProjectID = pipeline.ProjectID,
BookID = bookId,
ProjectName = pipeline.ProjectName,
BookTitle = pipeline.BookDisplayTitle,
Items = batchItems
};
await narrative.EnsureCandidatesAsync(batch);
narrativeCandidatesAfter = (await narrative.ListCandidatesByBookAsync(bookId)).Count;
}
logger.LogInformation(
"Story Intelligence deterministic materialisation completed for BookID={BookID}. PipelineID={PipelineID} Runs={RunCount} SceneResultsRead={SceneResultsRead} SceneResultsParsed={SceneResultsParsed} MetricsSaved={MetricsSaved} NarrativeCandidatesBefore={NarrativeCandidatesBefore} NarrativeCandidatesAfter={NarrativeCandidatesAfter}.",
bookId,
pipeline.StoryIntelligenceBookPipelineID,
scopedRuns.Count,
sceneResultsRead,
sceneResultsParsed,
metricValuesSaved,
narrativeCandidatesBefore,
narrativeCandidatesAfter);
return new StoryIntelligenceMaterialisationResult(
bookId,
pipeline.StoryIntelligenceBookPipelineID,
IsReady: true,
Reason: null,
RunCount: scopedRuns.Count,
ExpectedScenes: expectedScenes,
SceneResultsRead: sceneResultsRead,
SceneResultsParsed: sceneResultsParsed,
MetricValuesSaved: metricValuesSaved,
NarrativeCandidatesBefore: narrativeCandidatesBefore,
NarrativeCandidatesAfter: narrativeCandidatesAfter);
}
public static IReadOnlyList<SceneMetricValue> BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, SceneMetricType> metricMap)
{
var metrics = ReadMetrics(parsed);
if (metrics.Count == 0)
{
return [];
}
var values = new List<SceneMetricValue>();
var importedMetricTypeIds = new HashSet<int>();
foreach (var (name, metric) in metrics)
{
if (!metric.Score.HasValue || !metricMap.TryGetValue(name, out var metricType))
{
continue;
}
if (!importedMetricTypeIds.Add(metricType.MetricTypeID))
{
continue;
}
values.Add(new SceneMetricValue
{
MetricTypeID = metricType.MetricTypeID,
MetricName = metricType.MetricName,
Value = Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue),
Notes = metric.Confidence.HasValue ? $"Story Intelligence confidence {metric.Confidence.Value:0.##}." : "Imported from Story Intelligence."
});
}
return values;
}
public static IReadOnlyDictionary<string, SceneMetricType> BuildMetricMap(IReadOnlyList<SceneMetricType> metricTypes)
{
var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase);
var map = new Dictionary<string, SceneMetricType>(StringComparer.OrdinalIgnoreCase);
foreach (var metric in metricTypes)
{
map[$"metric_{metric.MetricTypeID}"] = metric;
var cleanedName = Clean(metric.MetricName);
if (!string.IsNullOrWhiteSpace(cleanedName))
{
map.TryAdd(cleanedName, metric);
}
}
foreach (var (schemaName, aliases) in MetricAliases)
{
var metric = aliases.Select(alias => byName.TryGetValue(Clean(alias), out var match) ? match : null).FirstOrDefault(match => match is not null);
if (metric is not null)
{
map[schemaName] = metric;
}
}
return map;
}
private static IReadOnlyDictionary<string, SceneIntelligenceMetric> ReadMetrics(SceneIntelligenceScene parsed)
{
if (parsed.MetricValues is { Count: > 0 })
{
return parsed.MetricValues
.Select(metric => new { Key = metric.Key?.Trim(), metric.Score, metric.Confidence })
.Where(metric => !string.IsNullOrWhiteSpace(metric.Key))
.ToDictionary(
metric => metric.Key!,
metric => new SceneIntelligenceMetric { Score = metric.Score, Confidence = metric.Confidence },
StringComparer.OrdinalIgnoreCase);
}
return parsed.Metrics is { Count: > 0 }
? parsed.Metrics
: new Dictionary<string, SceneIntelligenceMetric>(StringComparer.OrdinalIgnoreCase);
}
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 || direct?.MetricValues?.Count > 0 || direct?.Metrics?.Count > 0)
{
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 Clean(string? value)
=> string.IsNullOrWhiteSpace(value)
? string.Empty
: new string(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
private static readonly IReadOnlyDictionary<string, string[]> MetricAliases = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["overallIntensity"] = ["Overall Intensity", "Intensity"],
["tension"] = ["Tension"],
["emotionalWeight"] = ["Emotional Weight", "Emotion", "Emotional Resonance"],
["emotion"] = ["Emotion", "Emotional Weight", "Emotional Resonance"],
["action"] = ["Action"],
["conflict"] = ["Conflict", "Relationship Conflict", "Tension"],
["darkness"] = ["Darkness"],
["romance"] = ["Romance"],
["sexualCharge"] = ["Sexual Charge"],
["violence"] = ["Violence"],
["hopeLightness"] = ["Hope / Lightness", "Hope", "Lightness"],
["comedy"] = ["Comedy", "Humour", "Humor"],
["humour"] = ["Humour", "Humor", "Comedy", "Comic Relief"],
["humor"] = ["Humour", "Humor", "Comedy", "Comic Relief"],
["mystery"] = ["Mystery"],
["revelation"] = ["Revelation", "Reveal", "Mystery"],
["pacing"] = ["Pacing", "PacingIntensity", "Pacing Intensity", "Overall Intensity"],
["pacingIntensity"] = ["Pacing", "PacingIntensity", "Pacing Intensity", "Overall Intensity"]
};
}
public sealed record StoryIntelligenceMaterialisationResult(
int BookID,
int? StoryIntelligenceBookPipelineID,
bool IsReady,
string? Reason,
int RunCount,
int ExpectedScenes,
int SceneResultsRead,
int SceneResultsParsed,
int MetricValuesSaved,
int NarrativeCandidatesBefore,
int NarrativeCandidatesAfter)
{
public static StoryIntelligenceMaterialisationResult NotReady(int bookId, string reason)
=> new(bookId, null, false, reason, 0, 0, 0, 0, 0, 0, 0);
}

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,8 @@ public interface IStoryIntelligenceClient
string promptVersion, string promptVersion,
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? modelOverride = null, string? modelOverride = null,
int? maxOutputTokensOverride = null); int? maxOutputTokensOverride = null,
StoryIntelligenceResponseContract? responseContract = null);
StoryIntelligenceClientConfigurationStatus GetConfigurationStatus(); StoryIntelligenceClientConfigurationStatus GetConfigurationStatus();
} }
@ -157,6 +158,7 @@ public sealed class StoryIntelligenceClient(
Model = settings.EffectiveModel, Model = settings.EffectiveModel,
ChapterStructureModel = settings.EffectiveChapterStructureModel, ChapterStructureModel = settings.EffectiveChapterStructureModel,
SceneIntelligenceModel = settings.EffectiveSceneIntelligenceModel, SceneIntelligenceModel = settings.EffectiveSceneIntelligenceModel,
WholeBookPlotSynthesisModel = settings.EffectiveWholeBookPlotSynthesisModel,
MaxOutputTokens = settings.MaxOutputTokens, MaxOutputTokens = settings.MaxOutputTokens,
TimeoutSeconds = settings.TimeoutSeconds, TimeoutSeconds = settings.TimeoutSeconds,
ClientConstructed = true, ClientConstructed = true,
@ -168,7 +170,8 @@ public sealed class StoryIntelligenceClient(
string promptVersion, string promptVersion,
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? modelOverride = null, string? modelOverride = null,
int? maxOutputTokensOverride = null) int? maxOutputTokensOverride = null,
StoryIntelligenceResponseContract? responseContract = null)
{ {
if (string.IsNullOrWhiteSpace(settings.ApiKey)) if (string.IsNullOrWhiteSpace(settings.ApiKey))
{ {
@ -195,6 +198,20 @@ public sealed class StoryIntelligenceClient(
maxOutputTokens = settings.MaxOutputTokens; maxOutputTokens = settings.MaxOutputTokens;
} }
var textOptions = responseContract is null
? new Dictionary<string, object> { ["verbosity"] = "low" }
: new Dictionary<string, object>
{
["verbosity"] = "low",
["format"] = new
{
type = "json_schema",
name = responseContract.Name,
strict = responseContract.Strict,
schema = responseContract.Schema
}
};
var requestBody = JsonSerializer.Serialize(new var requestBody = JsonSerializer.Serialize(new
{ {
model = model, model = model,
@ -205,16 +222,14 @@ public sealed class StoryIntelligenceClient(
{ {
effort = "minimal" effort = "minimal"
}, },
text = new text = textOptions
{
verbosity = "low"
}
}, JsonOptions); }, JsonOptions);
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
try try
{ {
var (responseText, retryCount) = await SendWithRetryAsync(requestBody, cancellationToken); var (responseText, retryCount) = await SendWithRetryAsync(requestBody, cancellationToken);
EnsureUsableResponse(responseText, responseContract);
stopwatch.Stop(); stopwatch.Stop();
logger.LogInformation( logger.LogInformation(
"Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}", "Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
@ -290,6 +305,8 @@ public sealed class StoryIntelligenceClient(
} }
retryCount++; retryCount++;
await DelayForRetryAsync(attempt, cancellationToken, response.Headers.RetryAfter);
continue;
} }
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{ {
@ -309,7 +326,6 @@ public sealed class StoryIntelligenceClient(
continue; continue;
} }
await DelayForRetryAsync(attempt, cancellationToken);
} }
throw new InvalidOperationException("OpenAI request failed after retry attempts."); throw new InvalidOperationException("OpenAI request failed after retry attempts.");
@ -327,8 +343,20 @@ public sealed class StoryIntelligenceClient(
=> responseText.Contains("\"insufficient_quota\"", StringComparison.OrdinalIgnoreCase) => responseText.Contains("\"insufficient_quota\"", StringComparison.OrdinalIgnoreCase)
|| responseText.Contains("exceeded your current quota", StringComparison.OrdinalIgnoreCase); || responseText.Contains("exceeded your current quota", StringComparison.OrdinalIgnoreCase);
private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken) private static Task DelayForRetryAsync(
=> Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken); int attempt,
CancellationToken cancellationToken,
RetryConditionHeaderValue? retryAfter = null)
{
var retryAfterDelay = retryAfter?.Delta
?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : (TimeSpan?)null);
var exponentialDelay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
var delay = retryAfterDelay.HasValue && retryAfterDelay.Value > TimeSpan.Zero
? retryAfterDelay.Value
: exponentialDelay;
var cappedDelay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds, 60));
return Task.Delay(cappedDelay, cancellationToken);
}
private static string TrimForLog(string value) private static string TrimForLog(string value)
=> string.IsNullOrWhiteSpace(value) || value.Length <= 500 ? value : value[..500]; => string.IsNullOrWhiteSpace(value) || value.Length <= 500 ? value : value[..500];
@ -345,6 +373,33 @@ public sealed class StoryIntelligenceClient(
return (null, null); return (null, null);
} }
} }
private static void EnsureUsableResponse(string responseText, StoryIntelligenceResponseContract? responseContract)
{
if (responseContract is null)
{
return;
}
var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(responseText, JsonOptions)
?? throw new InvalidOperationException("OpenAI structured response envelope was empty.");
if (!string.IsNullOrWhiteSpace(response.Status)
&& !string.Equals(response.Status, "completed", StringComparison.OrdinalIgnoreCase))
{
var detail = response.IncompleteDetails?.Reason ?? response.Error?.Message ?? response.Status;
throw new InvalidOperationException($"OpenAI structured response did not complete: {detail}");
}
var refusal = response.Output?
.SelectMany(item => item.Content ?? [])
.FirstOrDefault(content => string.Equals(content.Type, "refusal", StringComparison.OrdinalIgnoreCase)
|| !string.IsNullOrWhiteSpace(content.Refusal))
?.Refusal;
if (!string.IsNullOrWhiteSpace(refusal))
{
throw new InvalidOperationException($"OpenAI structured response was refused: {TrimForLog(refusal)}");
}
}
} }
public sealed class StoryIntelligenceDiagnosticsService( public sealed class StoryIntelligenceDiagnosticsService(
@ -434,7 +489,8 @@ public sealed class StoryIntelligenceDryRunService(
completedPrompt, completedPrompt,
promptVersion, promptVersion,
cancellationToken, cancellationToken,
clientStatus.SceneIntelligenceModel); clientStatus.SceneIntelligenceModel,
responseContract: StoryIntelligenceSceneStructuredOutputSchema.Contract);
var sceneJson = ExtractSceneJson(result.RawResponseText); var sceneJson = ExtractSceneJson(result.RawResponseText);
var parsedScene = DeserializeScene(sceneJson); var parsedScene = DeserializeScene(sceneJson);
var validation = validator.Validate(parsedScene); var validation = validator.Validate(parsedScene);
@ -711,7 +767,15 @@ 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
@ -721,8 +785,14 @@ public sealed class ChapterStructureDryRunService(
internal sealed class OpenAIResponseEnvelope internal sealed class OpenAIResponseEnvelope
{ {
public string? Status { get; init; }
public List<OpenAIResponseOutputItem>? Output { get; init; } public List<OpenAIResponseOutputItem>? Output { get; init; }
public OpenAIResponseUsage? Usage { get; init; } public OpenAIResponseUsage? Usage { get; init; }
[JsonPropertyName("incomplete_details")]
public OpenAIResponseIncompleteDetails? IncompleteDetails { get; init; }
public OpenAIResponseError? Error { get; init; }
} }
internal sealed class OpenAIResponseOutputItem internal sealed class OpenAIResponseOutputItem
@ -734,6 +804,7 @@ internal sealed class OpenAIResponseContentItem
{ {
public string? Type { get; init; } public string? Type { get; init; }
public string? Text { get; init; } public string? Text { get; init; }
public string? Refusal { get; init; }
} }
internal sealed class OpenAIResponseUsage internal sealed class OpenAIResponseUsage
@ -745,12 +816,23 @@ internal sealed class OpenAIResponseUsage
public int? OutputTokens { get; init; } public int? OutputTokens { get; init; }
} }
internal sealed class OpenAIResponseIncompleteDetails
{
public string? Reason { get; init; }
}
internal sealed class OpenAIResponseError
{
public string? Message { get; init; }
}
public sealed class StoryIntelligenceClientConfigurationStatus public sealed class StoryIntelligenceClientConfigurationStatus
{ {
public bool ApiKeyConfigured { get; init; } public bool ApiKeyConfigured { get; init; }
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 WholeBookPlotSynthesisModel { get; init; } = string.Empty;
public int MaxOutputTokens { get; init; } public int MaxOutputTokens { get; init; }
public int TimeoutSeconds { get; init; } public int TimeoutSeconds { get; init; }
public bool ClientConstructed { get; init; } public bool ClientConstructed { get; init; }

View File

@ -0,0 +1,96 @@
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);
Task NotifyIfImportTerminalAsync(int bookId, int userId, 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;
}
await NotifyIfImportTerminalAsync(bookId, run.UserID, cancellationToken);
}
public async Task NotifyIfImportTerminalAsync(int bookId, int userId, CancellationToken cancellationToken = default)
{
var target = await pipelines.TryBeginCompletionNotificationAsync(bookId, userId);
if (target is null)
{
return;
}
var user = await users.GetByIdAsync(target.UserID);
if (user is null || string.IsNullOrWhiteSpace(user.Email))
{
logger.LogWarning("Story Intelligence import notification skipped because user {UserID} has no email target.", target.UserID);
return;
}
await emails.SendStoryIntelligenceImportNotificationAsync(user.Email, user.DisplayName, target);
logger.LogInformation(
"Queued Story Intelligence import {NotificationKind} notification for import session {ImportSessionID}, book {BookID}, user {UserID}.",
target.NotificationKind,
target.ImportSessionID,
target.BookID,
target.UserID);
}
}

View File

@ -6,6 +6,7 @@ namespace PlotLine.Services;
public interface IStoryIntelligencePipelineStateService public interface IStoryIntelligencePipelineStateService
{ {
Task<StoryIntelligenceBookPipelineState?> GetForBookAsync(int bookId, int userId); Task<StoryIntelligenceBookPipelineState?> GetForBookAsync(int bookId, int userId);
Task<StoryIntelligenceReviewCentreSummary?> GetReviewCentreSummaryAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId); Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId); Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId);
Task RecordChapterAnalysisQueuedAsync(int projectId, int bookId, int runId); Task RecordChapterAnalysisQueuedAsync(int projectId, int bookId, int runId);
@ -15,6 +16,9 @@ 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<StoryIntelligenceSceneAnalysisCompletion?> CompleteSceneAnalysisIfReadyAsync(int bookId, int userId);
Task<StoryIntelligenceSceneAnalysisRecoveryCandidate?> FindNextReadySceneAnalysisAsync();
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
} }
@ -26,6 +30,9 @@ public sealed class StoryIntelligencePipelineStateService(
=> await pipelines.GetByBookForUserAsync(bookId, userId) => await pipelines.GetByBookForUserAsync(bookId, userId)
?? await EnsureForBookAsync(bookId, userId); ?? await EnsureForBookAsync(bookId, userId);
public Task<StoryIntelligenceReviewCentreSummary?> GetReviewCentreSummaryAsync(int bookId, int userId)
=> pipelines.GetReviewCentreSummaryAsync(bookId, userId);
public Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId) public Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId)
=> pipelines.ListForUserAsync(userId); => pipelines.ListForUserAsync(userId);
@ -105,7 +112,8 @@ public sealed class StoryIntelligencePipelineStateService(
CurrentReviewStage = StoryIntelligencePipelineStages.LocationReview, CurrentReviewStage = StoryIntelligencePipelineStages.LocationReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview, Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null, CompletedUtc = null,
LastRunID = null LastRunID = null,
MarkCharacterReviewComplete = true
}); });
} }
@ -120,7 +128,8 @@ public sealed class StoryIntelligencePipelineStateService(
CurrentReviewStage = StoryIntelligencePipelineStages.AssetReview, CurrentReviewStage = StoryIntelligencePipelineStages.AssetReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview, Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null, CompletedUtc = null,
LastRunID = null LastRunID = null,
MarkLocationReviewComplete = true
}); });
} }
@ -135,7 +144,8 @@ public sealed class StoryIntelligencePipelineStateService(
CurrentReviewStage = StoryIntelligencePipelineStages.RelationshipReview, CurrentReviewStage = StoryIntelligencePipelineStages.RelationshipReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview, Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null, CompletedUtc = null,
LastRunID = null LastRunID = null,
MarkAssetReviewComplete = true
}); });
} }
@ -150,7 +160,8 @@ public sealed class StoryIntelligencePipelineStateService(
CurrentReviewStage = StoryIntelligencePipelineStages.KnowledgeReview, CurrentReviewStage = StoryIntelligencePipelineStages.KnowledgeReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview, Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null, CompletedUtc = null,
LastRunID = null LastRunID = null,
MarkRelationshipReviewComplete = true
}); });
} }
@ -160,15 +171,62 @@ public sealed class StoryIntelligencePipelineStateService(
{ {
ProjectID = projectId, ProjectID = projectId,
BookID = bookId, BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete, CurrentStage = StoryIntelligencePipelineStages.NarrativeReview,
LastCompletedStage = StoryIntelligencePipelineStages.KnowledgeImport, LastCompletedStage = StoryIntelligencePipelineStages.KnowledgeImport,
CurrentReviewStage = null, CurrentReviewStage = StoryIntelligencePipelineStages.NarrativeReview,
Status = StoryIntelligencePipelineStatuses.Complete, Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = DateTime.UtcNow, CompletedUtc = null,
LastRunID = null LastRunID = null,
MarkKnowledgeReviewComplete = true
}); });
} }
public async Task RecordNarrativeImportAsync(int projectId, int bookId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete,
LastCompletedStage = StoryIntelligencePipelineStages.NarrativeImport,
CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.Complete,
CompletedUtc = DateTime.UtcNow,
LastRunID = null,
MarkNarrativeReviewComplete = true
});
}
public async Task<StoryIntelligenceSceneAnalysisCompletion?> CompleteSceneAnalysisIfReadyAsync(int bookId, int userId)
{
var completion = await pipelines.TryCompleteSceneAnalysisAsync(bookId, userId);
LogSceneAnalysisCompletion(completion, "completed");
return completion;
}
public Task<StoryIntelligenceSceneAnalysisRecoveryCandidate?> FindNextReadySceneAnalysisAsync()
=> pipelines.FindNextReadySceneAnalysisAsync();
public Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId) public Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId)
=> pipelines.ListCommittedRunsByBookAsync(bookId, userId); => pipelines.ListCommittedRunsByBookAsync(bookId, userId);
private void LogSceneAnalysisCompletion(StoryIntelligenceSceneAnalysisCompletion? completion, string mode)
{
if (completion is not { CompletedNow: true })
{
return;
}
logger.LogInformation(
"Story Intelligence scene analysis {Mode} for book {BookID}. PipelineID={PipelineID} UserID={UserID} Scenes={CompletedScenes}/{TotalExpectedScenes} FailedScenes={FailedScenes} Runs={CompletedRunCount}/{RunCount}",
mode,
completion.BookID,
completion.StoryIntelligenceBookPipelineID,
completion.UserID,
completion.CompletedScenes,
completion.TotalExpectedScenes,
completion.FailedScenes,
completion.CompletedRunCount,
completion.RunCount);
}
} }

View File

@ -7,11 +7,27 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceProgressNotifier public interface IStoryIntelligenceProgressNotifier
{ {
Task PublishAsync(StoryIntelligenceRunProgressEvent progress); Task PublishAsync(StoryIntelligenceRunProgressEvent progress);
Task PublishGlobalAsync(int userId);
} }
public sealed class StoryIntelligenceProgressNotifier(IHubContext<StoryIntelligenceHub> hub) : IStoryIntelligenceProgressNotifier public sealed class StoryIntelligenceProgressNotifier(
IHubContext<StoryIntelligenceHub> hub,
IStoryIntelligenceGlobalProgressService globalProgress) : IStoryIntelligenceProgressNotifier
{ {
public Task PublishAsync(StoryIntelligenceRunProgressEvent progress) public async Task PublishAsync(StoryIntelligenceRunProgressEvent progress)
=> hub.Clients.Group(StoryIntelligenceHub.UserGroup(progress.UserID)) {
var userGroup = StoryIntelligenceHub.UserGroup(progress.UserID);
await hub.Clients.Group(userGroup)
.SendAsync("StoryIntelligenceRunProgressChanged", progress); .SendAsync("StoryIntelligenceRunProgressChanged", progress);
await PublishGlobalAsync(progress.UserID);
}
public async Task PublishGlobalAsync(int userId)
{
var userGroup = StoryIntelligenceHub.UserGroup(userId);
var aggregate = await globalProgress.GetForUserAsync(userId);
await hub.Clients.Group(userGroup)
.SendAsync("StoryIntelligenceGlobalProgressChanged", aggregate);
}
} }

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