Phase 20AH – Complete Chapter Intelligence & Begin Character Import

This commit is contained in:
Nick Beckley 2026-07-08 21:03:10 +01:00
parent 8afc0a3b40
commit 7a48a91f21
18 changed files with 950 additions and 11 deletions

View File

@ -164,6 +164,20 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
: RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
}
[HttpPost("story-intelligence/characters")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceCharacters(StoryIntelligenceCharacterImportForm form)
{
var (progress, result) = await storyIntelligence.ImportCharactersAsync(form.BatchID, form);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = form.BatchID });
}
[HttpGet("story-intelligence/complete")]
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
{

View File

@ -2397,7 +2397,7 @@ public sealed class ChapterRepository(ISqlConnectionFactory connectionFactory) :
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.Chapter_Save",
new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.RevisionStatusID },
new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID },
commandType: CommandType.StoredProcedure);
}
@ -2657,7 +2657,8 @@ public sealed class LookupRepository(ISqlConnectionFactory connectionFactory) :
TimeModes = (await result.ReadAsync<TimeMode>()).ToList(),
DurationUnits = (await result.ReadAsync<DurationUnit>()).ToList(),
TimeConfidences = (await result.ReadAsync<TimeConfidence>()).ToList(),
ScenePurposeTypes = (await result.ReadAsync<ScenePurposeType>()).ToList()
ScenePurposeTypes = (await result.ReadAsync<ScenePurposeType>()).ToList(),
ChapterPurposeTypes = (await result.ReadAsync<ChapterPurposeType>()).ToList()
};
}
}

View File

@ -437,6 +437,20 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
};
}
if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue)
{
await connection.ExecuteAsync(
"dbo.StoryIntelligenceImportCommit_UpdateChapterPlanning",
new
{
request.ChapterID,
Summary = request.ChapterSummary,
request.ChapterPurposeID
},
transaction,
commandType: CommandType.StoredProcedure);
}
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
for (var index = 0; index < orderedScenes.Count; index++)
{

View File

@ -0,0 +1,67 @@
# Phase 20AH - Chapter Intelligence and Character Import
Phase 20AH completes the first chapter-level import path and begins author-reviewed character import from existing stored Story Intelligence JSON.
## Chapter Intelligence Import
When a Story Intelligence scene commit runs, PlotDirector now also updates the target chapter inside the same import transaction.
Imported chapter fields:
- Chapter summary: populated from the Chapter Structure `chapterSummary` response.
- Chapter purpose: mapped to an existing Chapter Purpose lookup value where the stored Chapter Intelligence response supports a clear match.
The import does not concatenate scene summaries to create a chapter summary.
Chapter Purpose is a chapter-level lookup, separate from Scene Purpose. The default lookup values are:
- Opening
- Character Development
- Relationship Development
- World Building
- Investigation
- Revelation
- Escalation
- Turning Point
- Climax
- Resolution
- Transition
Chapter Purpose is editable on the standard chapter edit screen and visible on the chapter detail page.
## Character Import
After scenes have been created, the onboarding review page can show a character review section.
The review is built from stored Scene Intelligence results only. It does not call OpenAI again.
For each candidate, PlotDirector shows:
- Character name
- Number of scenes where the character appears or is mentioned
- Confidence band
- Possible aliases
- First appearance example
- Existing matched character where available
The author can approve, ignore, or rename new characters before import.
When approved:
- Existing characters are linked where names or aliases match.
- New characters are created only for approved candidates.
- Aliases from Scene Intelligence are saved where they do not duplicate the primary name or an existing alias.
- Scene People panels are populated for characters physically present in imported scenes.
- Mentioned-only characters can be created if approved, but they are not added as physically present scene appearances.
- Previously unresolved scene POV links are retried after character creation.
The import does not infer gender or create placeholder characters.
## Still Future Phases
- Locations
- Assets
- Relationships
- Knowledge changes
- Continuity warnings
- Real timeline events

View File

@ -345,6 +345,8 @@ public sealed class Chapter
public string ChapterTitle { get; set; } = string.Empty;
public int? POVCharacterID { get; set; }
public string? Summary { get; set; }
public int? ChapterPurposeID { get; set; }
public string? ChapterPurposeName { get; set; }
public int SortOrder { get; set; }
public int RevisionStatusID { get; set; }
public string RevisionStatusName { get; set; } = string.Empty;
@ -439,6 +441,14 @@ public sealed class RevisionStatus
public int SortOrder { get; set; }
}
public sealed class ChapterPurposeType
{
public int ChapterPurposeID { get; set; }
public string PurposeName { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsArchived { get; set; }
}
public sealed class TimeMode
{
public int TimeModeID { get; set; }
@ -2555,4 +2565,5 @@ public sealed class LookupData
public IReadOnlyList<DurationUnit> DurationUnits { get; init; } = [];
public IReadOnlyList<TimeConfidence> TimeConfidences { get; init; } = [];
public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; init; } = [];
public IReadOnlyList<ChapterPurposeType> ChapterPurposeTypes { get; init; } = [];
}

View File

@ -409,6 +409,8 @@ public sealed class StoryIntelligenceImportCommitRequest
public int TimeConfidenceID { get; init; }
public int RevisionStatusID { get; init; }
public int? TimelineNoteTypeID { get; init; }
public string? ChapterSummary { get; init; }
public int? ChapterPurposeID { get; init; }
public IReadOnlyList<StoryIntelligenceSceneImportItem> Scenes { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = [];
}

View File

@ -206,6 +206,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();

View File

@ -1377,7 +1377,8 @@ public sealed class ChapterService(
Project = project,
ChapterNumber = existing.Count + 1,
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)
};
}
@ -1404,11 +1405,13 @@ public sealed class ChapterService(
ChapterNumber = chapter.ChapterNumber,
ChapterTitle = chapter.ChapterTitle,
Summary = chapter.Summary,
ChapterPurposeID = chapter.ChapterPurposeID,
RevisionStatusID = chapter.RevisionStatusID,
IsLinkedToManuscript = chapter.IsLinkedToManuscript,
Book = book,
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)
};
}
@ -1427,6 +1430,7 @@ public sealed class ChapterService(
ChapterNumber = model.ChapterNumber,
ChapterTitle = model.ChapterTitle,
Summary = model.Summary,
ChapterPurposeID = model.ChapterPurposeID,
RevisionStatusID = model.RevisionStatusID
});
var book = await books.GetAsync(model.BookID);
@ -1454,6 +1458,9 @@ public sealed class ChapterService(
internal static IReadOnlyList<SelectListItem> ToSelectList<T>(IEnumerable<T> rows, Func<T, int> value, Func<T, string> text)
=> rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList();
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();
}
public sealed class SceneService(

View File

@ -14,6 +14,7 @@ public interface IOnboardingStoryIntelligenceService
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
}
@ -23,6 +24,7 @@ public sealed class OnboardingStoryIntelligenceService(
IManuscriptScanPreviewStore scanStore,
IStoryIntelligenceResultRepository runs,
IStoryIntelligenceImportCommitService commits,
IStoryIntelligenceCharacterImportService characterImport,
IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier,
@ -261,7 +263,8 @@ public sealed class OnboardingStoryIntelligenceService(
BookID = batch.BookID,
ProjectName = batch.ProjectName,
BookTitle = batch.BookTitle,
Chapters = chapters
Chapters = chapters,
CharacterReview = await characterImport.BuildReviewAsync(batch)
};
}
@ -328,6 +331,18 @@ public sealed class OnboardingStoryIntelligenceService(
});
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
}
var result = await characterImport.ImportAsync(batch, form);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
{
var progress = await GetProgressAsync(batchId);

View File

@ -0,0 +1,472 @@
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceCharacterImportService
{
Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form);
}
public sealed class StoryIntelligenceCharacterImportService(
IStoryIntelligenceResultRepository runs,
ICharacterRepository characters,
ISceneRepository scenes,
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static readonly HashSet<string> IgnoredNames = CreateSet("narrator", "unknown");
private static readonly HashSet<string> GenericPeople = CreateSet(
"man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse",
"police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress",
"clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group");
public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var data = await BuildCandidateDataAsync(batch);
return new StoryIntelligenceCharacterReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = data.Candidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
Candidates = data.Candidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
{
Key = candidate.Key,
CharacterName = candidate.DisplayName,
ImportName = candidate.DisplayName,
AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExistingCharacterID = candidate.ExistingCharacterID,
ExistingCharacterName = candidate.ExistingCharacterName
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form)
{
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing characters." };
}
var choices = form.Characters
.Where(choice => choice.Include && !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
if (choices.Count == 0)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one character to create or link." };
}
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var roleTypes = lookupData.RoleTypes;
var presenceTypes = lookupData.PresenceTypes;
var created = 0;
var linkedAppearances = 0;
var povLinks = 0;
var resolvedCharacters = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice))
{
continue;
}
var requestedName = Clean(choice.ImportName);
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var characterId = candidate.ExistingCharacterID
?? existingIndex.Find(importName)?.CharacterID
?? existingIndex.Find(candidate.DisplayName)?.CharacterID;
if (!characterId.HasValue)
{
characterId = await characters.SaveCharacterAsync(new Character
{
ProjectID = batch.ProjectID,
CharacterName = importName,
ShortName = FirstName(importName),
DefaultDescription = candidate.Description
});
created++;
}
AddResolvedName(resolvedCharacters, candidate.DisplayName, characterId.Value);
AddResolvedName(resolvedCharacters, importName, characterId.Value);
foreach (var alias in candidate.Aliases)
{
AddResolvedName(resolvedCharacters, alias, characterId.Value);
await TryAddAliasAsync(characterId.Value, alias, importName);
}
foreach (var appearance in candidate.PresentAppearances)
{
await characters.SaveSceneCharacterAsync(new SceneCharacter
{
SceneID = appearance.SceneID,
CharacterID = characterId.Value,
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
AppearanceNotes = appearance.Notes
});
linkedAppearances++;
}
}
foreach (var scene in data.SceneAnalyses)
{
var povName = Clean(scene.Parsed.PointOfView?.CharacterName);
if (string.IsNullOrWhiteSpace(povName) || !resolvedCharacters.TryGetValue(povName, out var characterId))
{
continue;
}
await scenes.UpdatePovCharacterAsync(scene.SceneID, characterId);
povLinks++;
}
logger.LogInformation(
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
batch.BatchID,
created,
linkedAppearances,
povLinks);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = linkedAppearances,
Message = $"Characters imported. {created:N0} character(s) created, {linkedAppearances:N0} scene appearance(s) linked, {povLinks:N0} POV link(s) updated."
};
}
private async Task<CharacterCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var groups = new Dictionary<string, CharacterCandidate>(StringComparer.OrdinalIgnoreCase);
var sceneAnalyses = new List<ImportedSceneAnalysis>();
var alreadyLinked = 0;
var hasCommittedScenes = false;
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
continue;
}
hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults)
{
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
}
sceneAnalyses.Add(new ImportedSceneAnalysis(importedScene.SceneID, parsed));
var existingAppearances = await characters.ListSceneCharactersAsync(importedScene.SceneID);
alreadyLinked += existingAppearances.Count;
foreach (var character in parsed.Characters ?? [])
{
AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances);
}
}
}
var candidates = groups.Values
.Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0)
.Where(candidate => candidate.PresentAppearances.Any(appearance => !appearance.AlreadyLinked))
.OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.DisplayName)
.ToList();
return new CharacterCandidateData(hasCommittedScenes, alreadyLinked, candidates, sceneAnalyses);
}
private static void AddMention(
Dictionary<string, CharacterCandidate> groups,
CharacterIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
SceneIntelligenceCharacter character,
IReadOnlyList<SceneCharacter> existingAppearances)
{
var name = Clean(character.Name);
if (string.IsNullOrWhiteSpace(name) || IgnoredNames.Contains(name) || IsGenericPerson(name))
{
return;
}
var key = Normalise(name);
if (!groups.TryGetValue(key, out var candidate))
{
var match = existingIndex.Find(name);
candidate = new CharacterCandidate(key, name, match?.CharacterID, match?.CharacterName);
groups[key] = candidate;
}
foreach (var alias in character.Aliases ?? [])
{
var cleanAlias = Clean(alias);
if (!string.IsNullOrWhiteSpace(cleanAlias) && !string.Equals(cleanAlias, candidate.DisplayName, StringComparison.OrdinalIgnoreCase))
{
candidate.Aliases.Add(cleanAlias);
}
}
var notes = Clean(character.Notes);
if (string.IsNullOrWhiteSpace(notes) && character.Actions?.Count > 0)
{
notes = Clean(string.Join("; ", character.Actions));
}
var appearance = new CharacterAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
character.RoleInScene,
character.MentionedOnly == true,
notes,
character.Confidence,
candidate.ExistingCharacterID.HasValue && existingAppearances.Any(appearance => appearance.CharacterID == candidate.ExistingCharacterID.Value));
if (character.MentionedOnly == true)
{
candidate.MentionedOnlyAppearances.Add(appearance);
}
else
{
candidate.PresentAppearances.Add(appearance);
}
candidate.Confidence = Max(candidate.Confidence, character.Confidence);
candidate.Description ??= notes;
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, character);
}
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
{
var index = new CharacterIndex();
foreach (var character in await characters.ListCharactersAsync(projectId))
{
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
{
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
}
}
return index;
}
private async Task TryAddAliasAsync(int characterId, string alias, string primaryName)
{
var cleanAlias = Clean(alias);
if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase))
{
return;
}
var existingAliases = await characters.ListAliasesAsync(characterId);
if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase)))
{
return;
}
await characters.AddAliasAsync(characterId, cleanAlias, null);
}
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
{
return rangeMatch;
}
return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null;
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
{
if (string.IsNullOrWhiteSpace(result.ParsedJson))
{
return null;
}
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
{
return direct;
}
using var document = JsonDocument.Parse(result.ParsedJson);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
}
return null;
}
private static int? MatchRole(IReadOnlyList<CharacterRoleInSceneType> roleTypes, string? role)
{
var value = Clean(role);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return roleTypes.FirstOrDefault(type =>
value.Contains(type.TypeName, StringComparison.OrdinalIgnoreCase)
|| type.TypeName.Contains(value, StringComparison.OrdinalIgnoreCase))?.CharacterRoleInSceneTypeID;
}
private static int? MatchPresence(IReadOnlyList<PresenceType> presenceTypes, bool mentionedOnly)
{
var preferred = mentionedOnly ? "Mentioned" : "Present";
return presenceTypes.FirstOrDefault(type => type.TypeName.Contains(preferred, StringComparison.OrdinalIgnoreCase))?.PresenceTypeID;
}
private static void AddResolvedName(Dictionary<string, int> map, string name, int characterId)
{
var clean = Clean(name);
if (!string.IsNullOrWhiteSpace(clean))
{
map[clean] = characterId;
}
}
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character)
{
var note = Clean(character.Notes);
if (!string.IsNullOrWhiteSpace(note))
{
return $"Scene {scene.SceneNumber:g}: {note}";
}
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary) ? $"Scene {scene.SceneNumber:g}: {scene.SceneTitle}" : $"Scene {scene.SceneNumber:g}: {summary}";
}
private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
private static string ConfidenceBand(decimal? confidence)
=> confidence switch
{
>= 0.75m => "High",
>= 0.45m => "Medium",
null => "Unknown",
_ => "Low"
};
private static string? FirstName(string name)
{
var parts = Clean(name).Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length > 1 ? parts[0] : null;
}
private static bool IsGenericPerson(string name)
=> GenericPeople.Contains(name) || GenericPeople.Contains(Normalise(name));
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int characterId, string characterName)
{
var key = Normalise(name);
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
}
}
public CharacterMatch? Find(string name)
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
}
private sealed record CharacterMatch(int CharacterID, string CharacterName);
private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName)
{
public string Key { get; } = key;
public string DisplayName { get; } = displayName;
public int? ExistingCharacterID { get; } = existingCharacterId;
public string? ExistingCharacterName { get; } = existingCharacterName;
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<CharacterAppearanceImport> PresentAppearances { get; } = [];
public List<CharacterAppearanceImport> MentionedOnlyAppearances { get; } = [];
public decimal? Confidence { get; set; }
public string? Description { get; set; }
public string? FirstAppearanceNote { get; set; }
}
private sealed record CharacterAppearanceImport(
int SceneID,
decimal SceneNumber,
string SceneTitle,
string? RoleInScene,
bool MentionedOnly,
string? Notes,
decimal? Confidence,
bool AlreadyLinked);
private sealed record ImportedSceneAnalysis(int SceneID, SceneIntelligenceScene Parsed);
private sealed record CharacterCandidateData(
bool HasCommittedScenes,
int AlreadyLinkedCount,
IReadOnlyList<CharacterCandidate> Candidates,
IReadOnlyList<ImportedSceneAnalysis> SceneAnalyses);
}

View File

@ -84,6 +84,8 @@ public sealed class StoryIntelligenceImportCommitService(
TimeConfidenceID = prepared.TimeConfidenceID,
RevisionStatusID = prepared.RevisionStatusID,
TimelineNoteTypeID = prepared.TimelineNoteTypeID,
ChapterSummary = prepared.ChapterSummary,
ChapterPurposeID = prepared.ChapterPurposeID,
Scenes = prepared.Scenes,
Warnings = prepared.Confirmation.Warnings
});
@ -158,7 +160,12 @@ public sealed class StoryIntelligenceImportCommitService(
}
var purposeMap = lookupData.ScenePurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ScenePurposeTypeID, StringComparer.OrdinalIgnoreCase);
var chapterPurposeMap = lookupData.ChapterPurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ChapterPurposeID, StringComparer.OrdinalIgnoreCase);
var metricMap = BuildMetricMap(activeMetricTypes);
var chapterResult = await storyRuns.GetChapterResultAsync(runId);
var parsedChapter = TryReadChapter(chapterResult);
var chapterSummary = Clean(parsedChapter?.ChapterSummary);
var chapterPurposeId = MatchChapterPurpose(parsedChapter, chapterPurposeMap);
var importScenes = new List<StoryIntelligenceSceneImportItem>();
foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
{
@ -216,7 +223,9 @@ public sealed class StoryIntelligenceImportCommitService(
timeModeId,
timeConfidenceId,
revisionStatusId,
timelineNoteTypeId);
timelineNoteTypeId,
string.IsNullOrWhiteSpace(chapterSummary) ? null : chapterSummary,
chapterPurposeId);
}
private static StoryIntelligenceSceneImportItem BuildImportScene(
@ -671,6 +680,84 @@ public sealed class StoryIntelligenceImportCommitService(
return null;
}
private static ChapterStructureModel? TryReadChapter(StoryIntelligenceSavedChapterResult? result)
{
if (string.IsNullOrWhiteSpace(result?.ParsedJson))
{
return null;
}
try
{
return JsonSerializer.Deserialize<ChapterStructureModel>(result.ParsedJson, JsonOptions);
}
catch (JsonException)
{
return null;
}
}
private static int? MatchChapterPurpose(ChapterStructureModel? chapter, IReadOnlyDictionary<string, int> purposeMap)
{
if (chapter is null || purposeMap.Count == 0)
{
return null;
}
var texts = new List<string>();
if (!string.IsNullOrWhiteSpace(chapter.ChapterSummary))
{
texts.Add(chapter.ChapterSummary);
}
if (chapter.ExtensionData is not null)
{
foreach (var name in new[] { "chapterPurpose", "primaryPurpose", "purpose" })
{
if (chapter.ExtensionData.TryGetValue(name, out var element) && element.ValueKind == JsonValueKind.String)
{
texts.Add(element.GetString() ?? string.Empty);
}
}
}
texts.AddRange((chapter.SceneBoundaries ?? []).Select(boundary => boundary.Reason ?? string.Empty));
var combined = Clean(string.Join(" ", texts));
if (string.IsNullOrWhiteSpace(combined))
{
return null;
}
var direct = purposeMap.FirstOrDefault(pair => combined.Contains(pair.Key, StringComparison.OrdinalIgnoreCase));
if (direct.Value > 0)
{
return direct.Value;
}
var lowered = combined.ToLowerInvariant();
foreach (var (key, value) in purposeMap)
{
var purpose = key.ToLowerInvariant();
if ((purpose.Contains("investigation") && ContainsAny(lowered, "investigat", "question", "search", "clue"))
|| (purpose.Contains("revelation") && ContainsAny(lowered, "reveal", "discover", "learns", "truth"))
|| (purpose.Contains("relationship") && ContainsAny(lowered, "relationship", "bond", "trust", "conflict between"))
|| (purpose.Contains("character") && ContainsAny(lowered, "character", "grief", "fear", "choice", "realises", "realizes"))
|| (purpose.Contains("escalation") && ContainsAny(lowered, "escalat", "danger", "worsens", "pressure"))
|| (purpose.Contains("turning") && ContainsAny(lowered, "turning point", "changes direction", "decision"))
|| (purpose.Contains("transition") && ContainsAny(lowered, "transition", "moves from", "sets up"))
|| (purpose.Contains("resolution") && ContainsAny(lowered, "resolve", "settles", "concludes"))
|| (purpose.Contains("world") && ContainsAny(lowered, "world", "setting", "institution", "rules")))
{
return value;
}
}
return null;
}
private static bool ContainsAny(string value, params string[] needles)
=> needles.Any(needle => value.Contains(needle, StringComparison.OrdinalIgnoreCase));
private static string? ExtractSummaryFromJsonText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
@ -781,5 +868,7 @@ public sealed class StoryIntelligenceImportCommitService(
int TimeModeID,
int TimeConfidenceID,
int RevisionStatusID,
int? TimelineNoteTypeID);
int? TimelineNoteTypeID,
string? ChapterSummary,
int? ChapterPurposeID);
}

View File

@ -0,0 +1,140 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.ChapterPurposeTypes', N'U') IS NULL
BEGIN
CREATE TABLE dbo.ChapterPurposeTypes
(
ChapterPurposeID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ChapterPurposeTypes PRIMARY KEY,
PurposeName nvarchar(100) NOT NULL,
SortOrder int NOT NULL CONSTRAINT DF_ChapterPurposeTypes_SortOrder DEFAULT 0,
IsArchived bit NOT NULL CONSTRAINT DF_ChapterPurposeTypes_IsArchived DEFAULT 0,
CONSTRAINT UQ_ChapterPurposeTypes_PurposeName UNIQUE (PurposeName)
);
END;
GO
MERGE dbo.ChapterPurposeTypes AS target
USING (VALUES
(N'Opening', 10),
(N'Character Development', 20),
(N'Relationship Development', 30),
(N'World Building', 40),
(N'Investigation', 50),
(N'Revelation', 60),
(N'Escalation', 70),
(N'Turning Point', 80),
(N'Climax', 90),
(N'Resolution', 100),
(N'Transition', 110)
) AS source (PurposeName, SortOrder)
ON target.PurposeName = source.PurposeName
WHEN MATCHED THEN UPDATE SET SortOrder = source.SortOrder, IsArchived = 0
WHEN NOT MATCHED THEN INSERT (PurposeName, SortOrder, IsArchived) VALUES (source.PurposeName, source.SortOrder, 0);
GO
IF COL_LENGTH(N'dbo.Chapters', N'ChapterPurposeID') IS NULL
ALTER TABLE dbo.Chapters ADD ChapterPurposeID int NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_Chapters_ChapterPurposeTypes')
ALTER TABLE dbo.Chapters ADD CONSTRAINT FK_Chapters_ChapterPurposeTypes FOREIGN KEY (ChapterPurposeID) REFERENCES dbo.ChapterPurposeTypes(ChapterPurposeID);
GO
CREATE OR ALTER PROCEDURE dbo.Lookup_All
AS
BEGIN
SET NOCOUNT ON;
SELECT RevisionStatusID, StatusName, SortOrder FROM dbo.RevisionStatuses ORDER BY SortOrder;
SELECT TimeModeID, TimeModeName, SortOrder FROM dbo.TimeModes ORDER BY SortOrder;
SELECT DurationUnitID, DurationUnitName, SortOrder FROM dbo.DurationUnits ORDER BY SortOrder;
SELECT TimeConfidenceID, TimeConfidenceName, SortOrder FROM dbo.TimeConfidences ORDER BY SortOrder;
SELECT ScenePurposeTypeID, PurposeName, SortOrder FROM dbo.ScenePurposeTypes WHERE IsArchived = 0 ORDER BY SortOrder;
SELECT ChapterPurposeID, PurposeName, SortOrder, IsArchived FROM dbo.ChapterPurposeTypes WHERE IsArchived = 0 ORDER BY SortOrder;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Chapter_ListByBook
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, c.Summary,
c.ChapterPurposeID, cpt.PurposeName AS ChapterPurposeName,
c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName,
c.CreatedDate, c.UpdatedDate, c.IsArchived, c.IsLinkedToManuscript,
c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc
FROM dbo.Chapters c
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID
LEFT JOIN dbo.ChapterPurposeTypes cpt ON cpt.ChapterPurposeID = c.ChapterPurposeID
WHERE c.BookID = @BookID AND c.IsArchived = 0
ORDER BY c.SortOrder, c.ChapterNumber;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Chapter_Get
@ChapterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, c.Summary,
c.ChapterPurposeID, cpt.PurposeName AS ChapterPurposeName,
c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName,
c.CreatedDate, c.UpdatedDate, c.IsArchived, c.IsLinkedToManuscript,
c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc
FROM dbo.Chapters c
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID
LEFT JOIN dbo.ChapterPurposeTypes cpt ON cpt.ChapterPurposeID = c.ChapterPurposeID
WHERE c.ChapterID = @ChapterID AND c.IsArchived = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Chapter_Save
@ChapterID int = NULL,
@BookID int,
@ChapterNumber decimal(10,2),
@ChapterTitle nvarchar(200),
@Summary nvarchar(max) = NULL,
@ChapterPurposeID int = NULL,
@RevisionStatusID int
AS
BEGIN
SET NOCOUNT ON;
IF @ChapterID IS NULL OR @ChapterID = 0
BEGIN
DECLARE @NextChapterOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Chapters WHERE BookID = @BookID), 0) + 10;
INSERT dbo.Chapters (BookID, ChapterNumber, ChapterTitle, Summary, ChapterPurposeID, SortOrder, RevisionStatusID)
VALUES (@BookID, @ChapterNumber, @ChapterTitle, @Summary, @ChapterPurposeID, @NextChapterOrder, @RevisionStatusID);
SELECT CAST(SCOPE_IDENTITY() AS int) AS ChapterID;
RETURN;
END;
UPDATE dbo.Chapters
SET ChapterNumber = @ChapterNumber,
ChapterTitle = @ChapterTitle,
Summary = @Summary,
ChapterPurposeID = @ChapterPurposeID,
RevisionStatusID = @RevisionStatusID,
UpdatedDate = SYSUTCDATETIME()
WHERE ChapterID = @ChapterID;
SELECT @ChapterID AS ChapterID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_UpdateChapterPlanning
@ChapterID int,
@Summary nvarchar(max) = NULL,
@ChapterPurposeID int = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.Chapters
SET Summary = COALESCE(NULLIF(LTRIM(RTRIM(@Summary)), N''), Summary),
ChapterPurposeID = COALESCE(@ChapterPurposeID, ChapterPurposeID),
UpdatedDate = SYSUTCDATETIME()
WHERE ChapterID = @ChapterID;
END;
GO

View File

@ -260,6 +260,9 @@ public sealed class ChapterEditViewModel
public string? Summary { get; set; }
[Display(Name = "Chapter purpose")]
public int? ChapterPurposeID { get; set; }
[Display(Name = "Revision status")]
public int RevisionStatusID { get; set; }
@ -268,6 +271,7 @@ public sealed class ChapterEditViewModel
public Book? Book { get; set; }
public Project? Project { get; set; }
public IReadOnlyList<SelectListItem> RevisionStatuses { get; set; } = [];
public IReadOnlyList<SelectListItem> ChapterPurposes { get; set; } = [];
}
public sealed class ChapterDetailViewModel

View File

@ -137,6 +137,7 @@ public sealed class StoryIntelligenceProgressViewModel
public string? ProjectName { get; init; }
public string? BookTitle { get; init; }
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
public int ChapterCount => Chapters.Count;
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
public int CommittedChapterCount => Chapters.Count(chapter => chapter.HasCompletedCommit);
@ -150,6 +151,43 @@ public sealed class StoryIntelligenceProgressViewModel
public bool HasActiveRuns => Chapters.Any(chapter => chapter.IsActive);
public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit);
public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit);
public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0;
}
public sealed class StoryIntelligenceCharacterReviewViewModel
{
public bool CanImport { get; init; }
public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { get; init; }
public IReadOnlyList<StoryIntelligenceCharacterReviewCandidateViewModel> Candidates { get; init; } = [];
}
public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
{
public string Key { get; init; } = string.Empty;
public string CharacterName { get; init; } = string.Empty;
public string ImportName { get; init; } = string.Empty;
public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown";
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
public string? ExampleFirstAppearance { get; init; }
public int? ExistingCharacterID { get; init; }
public string? ExistingCharacterName { get; init; }
public bool IsExistingMatch => ExistingCharacterID.HasValue;
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
}
public sealed class StoryIntelligenceCharacterImportForm
{
public Guid BatchID { get; set; }
public List<StoryIntelligenceCharacterImportChoiceForm> Characters { get; set; } = [];
}
public sealed class StoryIntelligenceCharacterImportChoiceForm
{
public string Key { get; set; } = string.Empty;
public bool Include { get; set; }
public string? ImportName { get; set; }
}
public sealed class StoryIntelligenceCompletionViewModel

View File

@ -147,7 +147,13 @@
<tr>
<td class="narrow">@chapter.ChapterNumber</td>
<td><a asp-controller="Chapters" asp-action="Details" asp-route-id="@chapter.ChapterID">@chapter.ChapterTitle</a></td>
<td><span class="status-pill">@chapter.RevisionStatusName</span></td>
<td>
<span class="status-pill">@chapter.RevisionStatusName</span>
@if (!string.IsNullOrWhiteSpace(chapter.ChapterPurposeName))
{
<span class="status-pill">@chapter.ChapterPurposeName</span>
}
</td>
<td><span class="status-pill">@(chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span></td>
<td>@chapter.Summary</td>
<td class="text-end">

View File

@ -26,6 +26,10 @@
</p>
<h1>Chapter @Model.Chapter.ChapterNumber: @Model.Chapter.ChapterTitle <help-icon key="chapters.overview" /></h1>
<span class="status-pill">@(Model.Chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span>
@if (!string.IsNullOrWhiteSpace(Model.Chapter.ChapterPurposeName))
{
<span class="status-pill">@Model.Chapter.ChapterPurposeName</span>
}
@if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary))
{
<p class="lead-text">@Model.Chapter.Summary</p>

View File

@ -20,7 +20,7 @@
<label asp-for="ChapterNumber" class="form-label">Chapter number <help-icon key="chapters.fields.chapterNumber" mode="inline" /></label>
<input asp-for="ChapterNumber" class="form-control" />
</div>
<div class="col-md-6">
<div class="col-md-3">
<label asp-for="ChapterTitle" class="form-label"></label>
<input asp-for="ChapterTitle" class="form-control" />
<span asp-validation-for="ChapterTitle" class="text-danger"></span>
@ -29,6 +29,10 @@
<label asp-for="RevisionStatusID" class="form-label">Revision status <help-icon key="chapters.fields.revisionStatus" mode="inline" /></label>
<select asp-for="RevisionStatusID" asp-items="Model.RevisionStatuses" class="form-select"></select>
</div>
<div class="col-md-3">
<label asp-for="ChapterPurposeID" class="form-label"></label>
<select asp-for="ChapterPurposeID" asp-items="Model.ChapterPurposes" class="form-select"></select>
</div>
<div class="col-12">
<label asp-for="Summary" class="form-label"></label>
<textarea asp-for="Summary" class="form-control" rows="4"></textarea>

View File

@ -17,7 +17,7 @@
<div class="onboarding-copy">
<p class="eyebrow">Step 9 of 9</p>
<h1 id="story-review-title">Review Story Intelligence Results</h1>
<p>PlotDirector has finished reading your manuscript. Nothing has been added to your project yet. Please review the detected scenes before creating them.</p>
<p>PlotDirector has finished reading your manuscript. Review the detected scenes first, then approve any characters you want to add or link.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -30,7 +30,7 @@
}
<section class="alert alert-info">
Only scenes will be created. Characters, locations, assets, relationships and knowledge will be imported during later Story Intelligence phases.
Scene creation runs first. Character import is reviewed separately and only creates or links approved characters. Locations, assets, relationships and knowledge remain for later Story Intelligence phases.
</section>
<section class="story-review-summary" aria-label="Analysis summary">
@ -178,6 +178,56 @@
Scene creation uses PlotDirector import safeguards. Duplicate imports are blocked, and failures roll back safely.
</section>
@if (Model.CharacterReview.HasCommittedScenes)
{
<section class="story-review-chapter-list" aria-label="Character review">
<h2>Review Characters</h2>
@if (Model.CharacterReview.Candidates.Count == 0)
{
<p class="text-muted">No new character suggestions are waiting. Existing scene character links are already up to date where PlotDirector could match them.</p>
}
else
{
<p>Approve the characters PlotDirector should create or link from the imported scenes. You can rename new characters before import.</p>
<form asp-action="ImportStoryIntelligenceCharacters" method="post">
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-scene-preview-list">
@for (var i = 0; i < Model.CharacterReview.Candidates.Count; i++)
{
var candidate = Model.CharacterReview.Candidates[i];
<article class="story-scene-preview">
<input type="hidden" name="Characters[@i].Key" value="@candidate.Key" />
<div>
<label>
<input type="checkbox" name="Characters[@i].Include" value="true" checked />
<strong>@candidate.CharacterName</strong>
</label>
<span class="story-review-status story-review-status--ready">@candidate.ActionLabel</span>
</div>
<dl>
<div><dt>Appears in</dt><dd>@candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</dd></div>
<div><dt>Confidence</dt><dd>@candidate.Confidence</dd></div>
<div><dt>Match</dt><dd>@(candidate.ExistingCharacterName ?? "New character")</dd></div>
</dl>
@if (candidate.PossibleAliases.Count > 0)
{
<p>Aliases: @string.Join(", ", candidate.PossibleAliases)</p>
}
@if (!string.IsNullOrWhiteSpace(candidate.ExampleFirstAppearance))
{
<p>@candidate.ExampleFirstAppearance</p>
}
<label class="form-label" for="character-import-name-@i">Import name</label>
<input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" readonly="@candidate.IsExistingMatch" />
</article>
}
</div>
<button class="btn btn-primary mt-3" type="submit">Import approved characters</button>
</form>
}
</section>
}
<div class="onboarding-actions">
@if (Model.BatchID.HasValue)
{