Compare commits
88 Commits
onboarding
...
pre-story-
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fab92f0e5 | |||
| decb975255 | |||
| d6d52b09e3 | |||
| a1cba8a057 | |||
| 5a7b8c79c7 | |||
| 794606f723 | |||
| 845b42e5fe | |||
| d32de17dcc | |||
| d2449a4683 | |||
| 99d818ed9f | |||
| 80d13c6a7f | |||
| b639b12163 | |||
| 85286428c2 | |||
| feed4549bf | |||
| ae0552af90 | |||
| b7f24954e1 | |||
| 6dec8a4345 | |||
| 78f401b417 | |||
| dd4b0badb6 | |||
| 730537daee | |||
| 0b74ee9ec0 | |||
| 0ec0167746 | |||
| e9f15e076b | |||
| a28495865a | |||
| 9c2a3215c5 | |||
| 6d367b8977 | |||
| a6ca129f4d | |||
| a319df2455 | |||
| 0cc25c570c | |||
| 96343138ea | |||
| 6d2340ae46 | |||
| 90d8f65fcf | |||
| 9c27ba6ffa | |||
| 3bf8f88f16 | |||
| 6aea9736d4 | |||
| 108df3db3e | |||
| 58075232e7 | |||
| caa70be9f4 | |||
| 1f5ab018b0 | |||
| ed4ad31466 | |||
| a2cf6a96a8 | |||
| 4f82dc308a | |||
| ea41ec3aac | |||
| 7d33323de7 | |||
| dabf63d91d | |||
| 358913f0ef | |||
| c10745bcc4 | |||
| b6aa53ee12 | |||
| dc5232378f | |||
| 3740658f3e | |||
| f76e5bc388 | |||
| e0c3171329 | |||
| 5a38560f47 | |||
| a6c870ab6d | |||
| 45aa1c7384 | |||
| fbb973574c | |||
| 4214779883 | |||
| 24f2120cb2 | |||
| c8148245c9 | |||
| 1b8b2a984a | |||
| a6145a25e8 | |||
| 398579c5c9 | |||
| 9d8d443067 | |||
| 0e75a95625 | |||
| 998c301d65 | |||
| adf93a075c | |||
| fbe8e60d59 | |||
| d62e825a17 | |||
| 3b2b25eeab | |||
| c2a6dbd849 | |||
| a90b98f8f0 | |||
| 1dfc620e00 | |||
| 2bb5decf63 | |||
| 25a8ce65fa | |||
| 77369a86fd | |||
| 1c7cf9bee1 | |||
| 1ead298fa8 | |||
| 09f003b0cd | |||
| 95f5c2925d | |||
| 43a57df070 | |||
| 451e384966 | |||
| 884a96d166 | |||
| fa2ffe4f17 | |||
| bba75f3ac4 | |||
| 90307757b4 | |||
| 9f0fdbc366 | |||
| f6b4263f31 | |||
| c96c20d18b |
166
AGENTS.md
Normal file
166
AGENTS.md
Normal 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
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
21
PlotLine/Controllers/NamesController.cs
Normal file
21
PlotLine/Controllers/NamesController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
256
PlotLine/Controllers/WritingController.cs
Normal file
256
PlotLine/Controllers/WritingController.cs
Normal 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" : "")}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
81
PlotLine/Data/ManuscriptTextStatisticsRepository.cs
Normal file
81
PlotLine/Data/ManuscriptTextStatisticsRepository.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
161
PlotLine/Data/NameIntelligenceRepository.cs
Normal file
161
PlotLine/Data/NameIntelligenceRepository.cs
Normal 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();
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
32
PlotLine/Data/Reference/Names/v1.0.0/DATA_CONTRACT.md
Normal file
32
PlotLine/Data/Reference/Names/v1.0.0/DATA_CONTRACT.md
Normal 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.
|
||||||
|
|
||||||
30
PlotLine/Data/Reference/Names/v1.0.0/METHODOLOGY.md
Normal file
30
PlotLine/Data/Reference/Names/v1.0.0/METHODOLOGY.md
Normal 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.
|
||||||
|
|
||||||
28
PlotLine/Data/Reference/Names/v1.0.0/README.md
Normal file
28
PlotLine/Data/Reference/Names/v1.0.0/README.md
Normal 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.
|
||||||
|
|
||||||
21
PlotLine/Data/Reference/Names/v1.0.0/VALIDATION.md
Normal file
21
PlotLine/Data/Reference/Names/v1.0.0/VALIDATION.md
Normal 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.
|
||||||
|
|
||||||
3068
PlotLine/Data/Reference/Names/v1.0.0/given_names.csv
Normal file
3068
PlotLine/Data/Reference/Names/v1.0.0/given_names.csv
Normal file
File diff suppressed because it is too large
Load Diff
71
PlotLine/Data/Reference/Names/v1.0.0/manifest.json
Normal file
71
PlotLine/Data/Reference/Names/v1.0.0/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
7
PlotLine/Data/Reference/Names/v1.0.0/name_locales.csv
Normal file
7
PlotLine/Data/Reference/Names/v1.0.0/name_locales.csv
Normal 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
|
||||||
|
526
PlotLine/Data/Reference/Names/v1.0.0/name_relationships.csv
Normal file
526
PlotLine/Data/Reference/Names/v1.0.0/name_relationships.csv
Normal 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
|
||||||
|
92011
PlotLine/Data/Reference/Names/v1.0.0/name_usages.csv
Normal file
92011
PlotLine/Data/Reference/Names/v1.0.0/name_usages.csv
Normal file
File diff suppressed because it is too large
Load Diff
15
PlotLine/Data/Reference/Names/v1.0.0/validation_report.json
Normal file
15
PlotLine/Data/Reference/Names/v1.0.0/validation_report.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"counts": {
|
||||||
|
"locales": 6,
|
||||||
|
"givenNames": 3067,
|
||||||
|
"relationships": 525,
|
||||||
|
"usages": 92010
|
||||||
|
},
|
||||||
|
"coverage": {
|
||||||
|
"usageGroups": 18402,
|
||||||
|
"expectedUsageGroups": 18402
|
||||||
|
},
|
||||||
|
"errors": [],
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
365
PlotLine/Data/WholeBookPlotIntelligenceRepository.cs
Normal file
365
PlotLine/Data/WholeBookPlotIntelligenceRepository.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
36
PlotLine/Docs/Features/NameIntelligence.md
Normal file
36
PlotLine/Docs/Features/NameIntelligence.md
Normal 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.
|
||||||
@ -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));
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
161
PlotLine/Models/NameIntelligenceModels.cs
Normal file
161
PlotLine/Models/NameIntelligenceModels.cs
Normal 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);
|
||||||
@ -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; }
|
||||||
|
|||||||
34
PlotLine/Models/SceneTitleDisplay.cs
Normal file
34
PlotLine/Models/SceneTitleDisplay.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
135
PlotLine/Models/StoryIntelligenceNarrativeModels.cs
Normal file
135
PlotLine/Models/StoryIntelligenceNarrativeModels.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
59
PlotLine/Models/StoryPosition.cs
Normal file
59
PlotLine/Models/StoryPosition.cs
Normal 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;
|
||||||
|
}
|
||||||
211
PlotLine/Models/WholeBookPlotIntelligenceModels.cs
Normal file
211
PlotLine/Models/WholeBookPlotIntelligenceModels.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
76
PlotLine/Models/WritingWorkspacePresenceModels.cs
Normal file
76
PlotLine/Models/WritingWorkspacePresenceModels.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
152
PlotLine/Services/CharacterSexHintAiService.cs
Normal file
152
PlotLine/Services/CharacterSexHintAiService.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
217
PlotLine/Services/CoreImportCharacterMappingService.cs
Normal file
217
PlotLine/Services/CoreImportCharacterMappingService.cs
Normal 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);
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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)));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
773
PlotLine/Services/ManuscriptStructureAnalysisService.cs
Normal file
773
PlotLine/Services/ManuscriptStructureAnalysisService.cs
Normal 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; }
|
||||||
|
}
|
||||||
215
PlotLine/Services/ManuscriptTextStatisticsService.cs
Normal file
215
PlotLine/Services/ManuscriptTextStatisticsService.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
597
PlotLine/Services/NameIntelligenceService.cs
Normal file
597
PlotLine/Services/NameIntelligenceService.cs
Normal 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
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" }
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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()),
|
||||||
|
|||||||
65
PlotLine/Services/StoryIntelligenceEntityTextNormaliser.cs
Normal file
65
PlotLine/Services/StoryIntelligenceEntityTextNormaliser.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
52
PlotLine/Services/StoryIntelligenceGlobalProgressService.cs
Normal file
52
PlotLine/Services/StoryIntelligenceGlobalProgressService.cs
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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() + "...";
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
333
PlotLine/Services/StoryIntelligenceMaterialisationService.cs
Normal file
333
PlotLine/Services/StoryIntelligenceMaterialisationService.cs
Normal 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);
|
||||||
|
}
|
||||||
1300
PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs
Normal file
1300
PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@ -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; }
|
||||||
|
|||||||
96
PlotLine/Services/StoryIntelligenceOperationalServices.cs
Normal file
96
PlotLine/Services/StoryIntelligenceOperationalServices.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
Loading…
x
Reference in New Issue
Block a user