Improve core import character mapping

This commit is contained in:
Nick Beckley 2026-08-25 13:14:16 +00:00
parent 0e75a95625
commit 9d8d443067
15 changed files with 557 additions and 12 deletions

View File

@ -65,7 +65,11 @@ var tests = new (string Name, Action Test)[]
("Structural import creates scenes before Word sync", StructuralImportCreatesScenesBeforeWordSync), ("Structural import creates scenes before Word sync", StructuralImportCreatesScenesBeforeWordSync),
("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText), ("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText),
("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText), ("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText),
("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls),
("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters),
("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters), ("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters),
("Core import remaps characters after review", CoreImportRemapsCharactersAfterReview),
("Scan review separates known matches from unresolved names", ScanReviewSeparatesKnownMatchesFromUnresolvedNames),
("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart), ("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart),
("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement), ("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement),
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes), ("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes),
@ -1381,6 +1385,46 @@ static void StructuralImportPersistsCanonicalSceneSourceText()
Assert(analyzer.Contains("item.IncludeInSourceText", StringComparison.Ordinal), "Scene source text should exclude non-prose structural paragraphs such as hidden markers/separators."); Assert(analyzer.Contains("item.IncludeInSourceText", StringComparison.Ordinal), "Scene source text should exclude non-prose structural paragraphs such as hidden markers/separators.");
} }
static void StructuralImportAddsSummariesWithoutExtraSceneAiCalls()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Chapter-Structure-Prompt-V2.md"));
var model = File.ReadAllText(Path.Combine(root, "Models/ChapterStructureModels.cs"));
var analyzer = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/154_Phase24C_CoreImportCharacterMappingAndSummaries.sql"));
Assert(prompt.Contains("structuralSummary", StringComparison.Ordinal), "Chapter boundary prompt should request a per-scene structural summary.");
Assert(prompt.Contains("25 to 40 words", StringComparison.Ordinal), "Structural summaries should be short factual summaries.");
Assert(model.Contains("public string? StructuralSummary", StringComparison.Ordinal), "Chapter boundary model should carry structural summaries.");
Assert(analyzer.Contains("StructuralSummary = Trim(boundary.StructuralSummary", StringComparison.Ordinal), "Boundary analysis should carry summaries into structural scenes.");
Assert(onboarding.Contains("SaveSceneStructuralSummaryAsync", StringComparison.Ordinal), "Core import should persist structural summaries after canonical SceneIDs exist.");
Assert(migration.Contains("SceneStructuralSummary_Save", StringComparison.Ordinal), "Structural summaries should be saved through a stored procedure.");
Assert(analyzer.Split("ExecutePromptAsync", StringSplitOptions.None).Length - 1 == 1, "Structural summaries must be produced by the existing chapter-boundary call, not extra scene calls.");
}
static void CoreImportDeterministicallyMapsKnownCharacters()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/CoreImportCharacterMappingService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/154_Phase24C_CoreImportCharacterMappingAndSummaries.sql"));
Assert(service.Contains("ICoreImportCharacterMappingService", StringComparison.Ordinal), "Deterministic character mapping service should be explicit.");
Assert(service.Contains("ListSceneManuscriptSourcesByBookAsync", StringComparison.Ordinal), "Mapping should scan persisted canonical scene source text.");
Assert(service.Contains("characters.ListCharactersAsync(projectId)", StringComparison.Ordinal), "Mapping should use known project characters before AI discovery.");
Assert(service.Contains("characters.ListAliasesAsync", StringComparison.Ordinal), "Mapping should include accepted aliases.");
Assert(service.Contains("GenericReferences", StringComparison.Ordinal), "Mapping should reject generic family/role labels.");
Assert(service.Contains("AddUniqueTokenTerm(index, character.CharacterID, tokens[0], tokenOwners)", StringComparison.Ordinal), "First-name matching should require unique ownership.");
Assert(service.Contains("AddUniqueTokenTerm(index, character.CharacterID, tokens[^1], tokenOwners)", StringComparison.Ordinal), "Surname-only matching should require unique ownership.");
Assert(service.Contains("tokenOwners.GetValueOrDefault(token) == 1", StringComparison.Ordinal), "Token matching should only add unique first/surname terms.");
Assert(service.Contains(@"(?<![\p{{L}}\p{{N}}])", StringComparison.Ordinal), "Matching should use non-letter/number boundaries to avoid substring false positives.");
Assert(service.Contains("MatchSource = MatchSource", StringComparison.Ordinal), "Saved links should carry deterministic evidence source.");
Assert(repository.Contains("SceneManuscriptSource_ListByBook", StringComparison.Ordinal), "Scene source listing should use a stored procedure.");
Assert(migration.Contains("OccurrenceCount int NULL", StringComparison.Ordinal), "Scene-character evidence should persist occurrence counts.");
Assert(migration.Contains("FirstOccurrenceIndex int NULL", StringComparison.Ordinal), "Scene-character evidence should persist first occurrence.");
}
static void CoreImportWizardOrdersScenesBeforeCharacters() static void CoreImportWizardOrdersScenesBeforeCharacters()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
@ -1397,6 +1441,28 @@ static void CoreImportWizardOrdersScenesBeforeCharacters()
Assert(complete.Contains("Word may now be closed; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion."); Assert(complete.Contains("Word may now be closed; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion.");
} }
static void CoreImportRemapsCharactersAfterReview()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var aliasSave = onboarding.IndexOf("await AddScanCharacterAliasesAsync(completedReview, completedResult)", StringComparison.Ordinal);
var remap = onboarding.IndexOf("await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID)", aliasSave, StringComparison.Ordinal);
Assert(aliasSave >= 0, "Character review should save accepted aliases.");
Assert(remap > aliasSave, "Character review should rerun deterministic mapping after accepted aliases are saved.");
}
static void ScanReviewSeparatesKnownMatchesFromUnresolvedNames()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var review = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
Assert(review.Contains("Already matched known characters", StringComparison.Ordinal), "Character review should separate already-matched known characters.");
Assert(review.Contains("Likely new characters", StringComparison.Ordinal), "Character review should focus unresolved likely characters separately.");
Assert(review.Contains("Possible new names or aliases", StringComparison.Ordinal), "Character review should label possible alias/new-name cases clearly.");
Assert(review.Contains("Generic or ambiguous references", StringComparison.Ordinal), "Character review should label generic/ambiguous references distinctly.");
}
static void StoryIntelligenceUsesPersistedSceneSourceAfterRestart() static void StoryIntelligenceUsesPersistedSceneSourceAfterRestart()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
@ -1404,6 +1470,7 @@ static void StoryIntelligenceUsesPersistedSceneSourceAfterRestart()
var sourceRepository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceSourceRepository.cs")); var sourceRepository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceSourceRepository.cs"));
var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var existingQueue = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceExistingChapterQueueService.cs")); var existingQueue = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceExistingChapterQueueService.cs"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
Assert(migration.Contains("INNER JOIN dbo.SceneManuscriptSources", StringComparison.Ordinal), "Chapter source SQL should aggregate persisted canonical scene source text."); Assert(migration.Contains("INNER JOIN dbo.SceneManuscriptSources", StringComparison.Ordinal), "Chapter source SQL should aggregate persisted canonical scene source text.");
Assert(migration.Contains("STRING_AGG", StringComparison.Ordinal), "Chapter source SQL should reconstruct chapter text from persisted scene text in order."); Assert(migration.Contains("STRING_AGG", StringComparison.Ordinal), "Chapter source SQL should reconstruct chapter text from persisted scene text in order.");
@ -1411,6 +1478,7 @@ static void StoryIntelligenceUsesPersistedSceneSourceAfterRestart()
Assert(onboardingStory.Contains("durableSource = await sources.GetChapterSourceAsync", StringComparison.Ordinal), "Onboarding Story Intelligence should read durable chapter text from scene sources."); Assert(onboardingStory.Contains("durableSource = await sources.GetChapterSourceAsync", StringComparison.Ordinal), "Onboarding Story Intelligence should read durable chapter text from scene sources.");
Assert(onboardingStory.Contains("CoreImportComplete", StringComparison.Ordinal), "Optional onboarding Story Intelligence should require core import completion."); Assert(onboardingStory.Contains("CoreImportComplete", StringComparison.Ordinal), "Optional onboarding Story Intelligence should require core import completion.");
Assert(existingQueue.Contains("sources.GetChapterSourceAsync", StringComparison.Ordinal), "Later Story Intelligence queueing should start from persisted chapter/scene source, not onboarding DTOs."); Assert(existingQueue.Contains("sources.GetChapterSourceAsync", StringComparison.Ordinal), "Later Story Intelligence queueing should start from persisted chapter/scene source, not onboarding DTOs.");
Assert(runner.Contains("structuralSummary = boundary.StructuralSummary", StringComparison.Ordinal), "Scene Intelligence context should receive structural summaries from the boundary pass.");
} }
static void WordSyncRequiresSaveAcknowledgement() static void WordSyncRequiresSaveAcknowledgement()

View File

@ -13,6 +13,8 @@ public interface IOnboardingBuildRepository
Task MarkMarkerSyncFailedAsync(int buildId, string message); Task MarkMarkerSyncFailedAsync(int buildId, string message);
Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request); Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request);
Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request); Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request);
Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary);
Task<IReadOnlyList<SceneManuscriptSourceRecord>> ListSceneManuscriptSourcesByBookAsync(int bookId);
} }
public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFactory) : IOnboardingBuildRepository public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFactory) : IOnboardingBuildRepository
@ -134,6 +136,25 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
}, },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.SceneStructuralSummary_Save",
new { SceneID = sceneId, StructuralSummary = structuralSummary },
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 sealed class OnboardingManuscriptBuildRequest public sealed class OnboardingManuscriptBuildRequest
@ -197,3 +218,20 @@ public sealed class SceneManuscriptSourceSaveRequest
public int? WordEndParagraphIndex { get; init; } public int? WordEndParagraphIndex { get; init; }
public string SourceKind { get; init; } = "StructuralImport"; public string SourceKind { get; init; } = "StructuralImport";
} }
public sealed class SceneManuscriptSourceRecord
{
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; } = string.Empty;
public string? StructuralSummary { get; init; }
}

View File

@ -3864,7 +3864,12 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
sceneCharacter.OutfitDescription, sceneCharacter.OutfitDescription,
sceneCharacter.PhysicalCondition, sceneCharacter.PhysicalCondition,
sceneCharacter.EmotionalState, sceneCharacter.EmotionalState,
sceneCharacter.KnowledgeNotes sceneCharacter.KnowledgeNotes,
sceneCharacter.MatchSource,
sceneCharacter.MatchedText,
sceneCharacter.OccurrenceCount,
sceneCharacter.FirstOccurrenceIndex,
PreserveExistingWhenNull = string.Equals(sceneCharacter.MatchSource, "CoreImportDeterministic", StringComparison.Ordinal)
}, },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }

View File

@ -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 and provide a light factual summary for each detected scene.
## Operating Principles ## Operating Principles
@ -117,11 +117,14 @@ Each `sceneBoundaries` item must contain exactly:
- `sceneNumber` - `sceneNumber`
- `startParagraph` - `startParagraph`
- `endParagraph` - `endParagraph`
- `structuralSummary`
- `confidence` - `confidence`
- `reason` - `reason`
Use confidence values from `0.0` to `1.0`. Use confidence values from `0.0` to `1.0`.
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 short factual reasons, such as: Use short factual reasons, such as:
`Location changes from house to train.` `Location changes from house to train.`
@ -133,7 +136,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.
@ -149,6 +152,7 @@ Do not output:
"sceneNumber": 1, "sceneNumber": 1,
"startParagraph": 1, "startParagraph": 1,
"endParagraph": 3, "endParagraph": 3,
"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.",
"confidence": 0.86, "confidence": 0.86,
"reason": "Opening scene remains in the same place and time." "reason": "Opening scene remains in the same place and time."
} }

View File

@ -18,6 +18,7 @@ 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? StructuralSummary { get; init; }
public decimal? Confidence { get; init; } public decimal? Confidence { get; init; }
public string? Reason { get; init; } public string? Reason { get; init; }

View File

@ -377,6 +377,7 @@ public sealed class Scene
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? Summary { get; set; } public string? Summary { get; set; }
public string? StructuralSummary { get; set; }
public int? POVCharacterID { get; set; } public int? POVCharacterID { get; set; }
public int? PrimaryLocationID { get; set; } public int? PrimaryLocationID { get; set; }
public int? FloorPlanID { get; set; } public int? FloorPlanID { get; set; }
@ -997,6 +998,10 @@ public sealed class SceneCharacter
public string? PhysicalCondition { get; set; } public string? PhysicalCondition { get; set; }
public string? EmotionalState { get; set; } public string? EmotionalState { get; set; }
public string? KnowledgeNotes { get; set; } public string? KnowledgeNotes { get; set; }
public string? MatchSource { get; set; }
public string? MatchedText { get; set; }
public int? OccurrenceCount { get; set; }
public int? FirstOccurrenceIndex { get; set; }
public decimal SceneNumber { get; set; } public decimal SceneNumber { get; set; }
public string SceneTitle { get; set; } = string.Empty; public string SceneTitle { get; set; } = string.Empty;
public decimal ChapterNumber { get; set; } public decimal ChapterNumber { get; set; }

View File

@ -216,6 +216,7 @@ public class Program
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<IManuscriptStructureAnalysisService, ManuscriptStructureAnalysisService>();
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>();

View File

@ -92,6 +92,7 @@ public static class ChapterStructureBoundaryNormaliser
SceneNumber = sceneNumber, SceneNumber = sceneNumber,
StartParagraph = boundary.StartParagraph, StartParagraph = boundary.StartParagraph,
EndParagraph = boundary.EndParagraph, EndParagraph = boundary.EndParagraph,
StructuralSummary = boundary.StructuralSummary,
Confidence = boundary.Confidence, Confidence = boundary.Confidence,
Reason = boundary.Reason, Reason = boundary.Reason,
ExtensionData = boundary.ExtensionData ExtensionData = boundary.ExtensionData

View File

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

View File

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

View File

@ -95,6 +95,7 @@ public sealed class ManuscriptStructureAnalysisService(
EndParagraph = end, EndParagraph = end,
WordStartPosition = startMap.WordParagraphIndex, WordStartPosition = startMap.WordParagraphIndex,
WordEndPosition = endMap.WordParagraphIndex, WordEndPosition = endMap.WordParagraphIndex,
StructuralSummary = Trim(boundary.StructuralSummary, 600),
Reason = boundary.Reason, Reason = boundary.Reason,
Confidence = boundary.Confidence Confidence = boundary.Confidence
}); });
@ -196,6 +197,17 @@ public sealed class ManuscriptStructureAnalysisService(
=> string.IsNullOrWhiteSpace(value) => string.IsNullOrWhiteSpace(value)
? 0 ? 0
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; : 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();
}
} }
public sealed class ManuscriptStructureAnalysisRequest public sealed class ManuscriptStructureAnalysisRequest
@ -237,6 +249,7 @@ public sealed class ManuscriptStructureSceneBoundary
public int EndParagraph { get; init; } public int EndParagraph { get; init; }
public int WordStartPosition { get; init; } public int WordStartPosition { get; init; }
public int WordEndPosition { get; init; } public int WordEndPosition { get; init; }
public string? StructuralSummary { get; init; }
public string? Reason { get; init; } public string? Reason { get; init; }
public decimal? Confidence { get; init; } public decimal? Confidence { get; init; }
} }

View File

@ -40,6 +40,7 @@ public sealed class OnboardingService(
ICharacterRepository characters, ICharacterRepository characters,
IOnboardingBuildRepository builds, IOnboardingBuildRepository builds,
IManuscriptStructureAnalysisService structureAnalysis, IManuscriptStructureAnalysisService structureAnalysis,
ICoreImportCharacterMappingService coreImportCharacterMapping,
ICurrentUserService currentUser) : IOnboardingService ICurrentUserService currentUser) : IOnboardingService
{ {
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal) private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
@ -328,6 +329,7 @@ public sealed class OnboardingService(
}; };
await SaveStructuralChapterSourcesAsync(preview, result); await SaveStructuralChapterSourcesAsync(preview, result);
await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result); await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result);
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
await scanPreviews.SaveBuildResultAsync(userId, result); await scanPreviews.SaveBuildResultAsync(userId, result);
await PublishBuildProgress(userId, preview.PreviewID, "Updating and saving Word markers. Keep Word and this browser page open.", 90, progress); await PublishBuildProgress(userId, preview.PreviewID, "Updating and saving Word markers. Keep Word and this browser page open.", 90, progress);
return result; return result;
@ -440,6 +442,7 @@ public sealed class OnboardingService(
await scanPreviews.SaveReviewAsync(userId, completedReview); await scanPreviews.SaveReviewAsync(userId, completedReview);
await scanPreviews.SaveBuildResultAsync(userId, completedResult); await scanPreviews.SaveBuildResultAsync(userId, completedResult);
await AddScanCharacterAliasesAsync(completedReview, completedResult); await AddScanCharacterAliasesAsync(completedReview, completedResult);
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
return completedResult; return completedResult;
} }
@ -547,6 +550,11 @@ public sealed class OnboardingService(
WordStartParagraphIndex = scene.WordStartPosition, WordStartParagraphIndex = scene.WordStartPosition,
WordEndParagraphIndex = scene.WordEndPosition WordEndParagraphIndex = scene.WordEndPosition
}); });
if (!string.IsNullOrWhiteSpace(scene.StructuralSummary))
{
await builds.SaveSceneStructuralSummaryAsync(sceneId, scene.StructuralSummary);
}
} }
} }

View File

@ -886,6 +886,7 @@ public sealed class PersistedStoryIntelligenceRunner(
sceneId = (int?)null, sceneId = (int?)null,
chapterNumber = ChapterNumber(run), chapterNumber = ChapterNumber(run),
sceneNumber = boundary.SceneNumber, sceneNumber = boundary.SceneNumber,
structuralSummary = boundary.StructuralSummary,
sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}", sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}",
knownCharacters = KnownCharacters(run) knownCharacters = KnownCharacters(run)
}, JsonOptions); }, JsonOptions);

View File

@ -0,0 +1,169 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.Scenes', N'StructuralSummary') IS NULL
BEGIN
ALTER TABLE dbo.Scenes ADD StructuralSummary nvarchar(max) NULL;
END;
GO
IF COL_LENGTH(N'dbo.SceneCharacters', N'MatchSource') IS NULL
BEGIN
ALTER TABLE dbo.SceneCharacters ADD MatchSource nvarchar(80) NULL;
END;
GO
IF COL_LENGTH(N'dbo.SceneCharacters', N'MatchedText') IS NULL
BEGIN
ALTER TABLE dbo.SceneCharacters ADD MatchedText nvarchar(200) NULL;
END;
GO
IF COL_LENGTH(N'dbo.SceneCharacters', N'OccurrenceCount') IS NULL
BEGIN
ALTER TABLE dbo.SceneCharacters ADD OccurrenceCount int NULL;
END;
GO
IF COL_LENGTH(N'dbo.SceneCharacters', N'FirstOccurrenceIndex') IS NULL
BEGIN
ALTER TABLE dbo.SceneCharacters ADD FirstOccurrenceIndex int NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneStructuralSummary_Save
@SceneID int,
@StructuralSummary nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.Scenes
SET StructuralSummary = NULLIF(LTRIM(RTRIM(@StructuralSummary)), N''),
Summary = CASE
WHEN NULLIF(LTRIM(RTRIM(Summary)), N'') IS NULL THEN NULLIF(LTRIM(RTRIM(@StructuralSummary)), N'')
ELSE Summary
END,
UpdatedDate = SYSUTCDATETIME()
WHERE SceneID = @SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_ListByBook
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT source.SceneID, source.ProjectID, source.BookID, source.ChapterID, source.SourceText,
source.SourceWordCount, source.SourceCharacterCount, source.SourceStartParagraph, source.SourceEndParagraph,
source.WordStartParagraphIndex, source.WordEndParagraphIndex, source.SourceKind, scene.StructuralSummary
FROM dbo.SceneManuscriptSources source
INNER JOIN dbo.Scenes scene ON scene.SceneID = source.SceneID
INNER JOIN dbo.Chapters chapter ON chapter.ChapterID = source.ChapterID
WHERE source.BookID = @BookID
AND scene.IsArchived = 0
AND chapter.IsArchived = 0
ORDER BY chapter.SortOrder, chapter.ChapterNumber, scene.SortOrder, scene.SceneNumber, scene.SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneCharacter_ListByScene
@SceneID int
AS
BEGIN
SET NOCOUNT ON;
SELECT sc.SceneCharacterID, sc.SceneID, sc.CharacterID, c.ProjectID, c.CharacterName, c.ShortName, c.BirthDate, c.AgeAtSeriesStart,
sc.RoleInSceneTypeID, role.TypeName AS RoleInSceneTypeName, sc.PresenceTypeID, presence.TypeName AS PresenceTypeName,
sc.LocationID, loc.LocationName, sc.EntryLocationID, entryLoc.LocationName AS EntryLocationName,
sc.ExitLocationID, exitLoc.LocationName AS ExitLocationName,
sc.AppearanceNotes, sc.OutfitDescription, sc.PhysicalCondition, sc.EmotionalState, sc.KnowledgeNotes,
sc.MatchSource, sc.MatchedText, sc.OccurrenceCount, sc.FirstOccurrenceIndex,
sc.CreatedDate, sc.UpdatedDate
FROM dbo.SceneCharacters sc
INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID
LEFT JOIN dbo.CharacterRoleInSceneTypes role ON role.CharacterRoleInSceneTypeID = sc.RoleInSceneTypeID
LEFT JOIN dbo.PresenceTypes presence ON presence.PresenceTypeID = sc.PresenceTypeID
LEFT JOIN dbo.Locations loc ON loc.LocationID = sc.LocationID
LEFT JOIN dbo.Locations entryLoc ON entryLoc.LocationID = sc.EntryLocationID
LEFT JOIN dbo.Locations exitLoc ON exitLoc.LocationID = sc.ExitLocationID
WHERE sc.SceneID = @SceneID AND c.IsArchived = 0
ORDER BY role.SortOrder, c.CharacterName;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneCharacter_Save
@SceneCharacterID int = NULL,
@SceneID int,
@CharacterID int,
@RoleInSceneTypeID int = NULL,
@PresenceTypeID int = NULL,
@LocationID int = NULL,
@EntryLocationID int = NULL,
@ExitLocationID int = NULL,
@AppearanceNotes nvarchar(max) = NULL,
@OutfitDescription nvarchar(max) = NULL,
@PhysicalCondition nvarchar(max) = NULL,
@EmotionalState nvarchar(max) = NULL,
@KnowledgeNotes nvarchar(max) = NULL,
@MatchSource nvarchar(80) = NULL,
@MatchedText nvarchar(200) = NULL,
@OccurrenceCount int = NULL,
@FirstOccurrenceIndex int = NULL,
@PreserveExistingWhenNull bit = 0
AS
BEGIN
SET NOCOUNT ON;
IF @SceneCharacterID IS NULL OR @SceneCharacterID = 0
BEGIN
SELECT @SceneCharacterID = SceneCharacterID
FROM dbo.SceneCharacters
WHERE SceneID = @SceneID AND CharacterID = @CharacterID;
END
IF @SceneCharacterID IS NULL OR @SceneCharacterID = 0
BEGIN
INSERT dbo.SceneCharacters
(
SceneID, CharacterID, RoleInSceneTypeID, PresenceTypeID, LocationID, EntryLocationID, ExitLocationID,
AppearanceNotes, OutfitDescription, PhysicalCondition, EmotionalState, KnowledgeNotes,
MatchSource, MatchedText, OccurrenceCount, FirstOccurrenceIndex
)
VALUES
(
@SceneID, @CharacterID, @RoleInSceneTypeID, @PresenceTypeID, @LocationID, @EntryLocationID, @ExitLocationID,
@AppearanceNotes, @OutfitDescription, @PhysicalCondition, @EmotionalState, @KnowledgeNotes,
@MatchSource, @MatchedText, @OccurrenceCount, @FirstOccurrenceIndex
);
SET @SceneCharacterID = CAST(SCOPE_IDENTITY() AS int);
END
ELSE
BEGIN
UPDATE dbo.SceneCharacters
SET SceneID = @SceneID,
CharacterID = @CharacterID,
RoleInSceneTypeID = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@RoleInSceneTypeID, RoleInSceneTypeID) ELSE @RoleInSceneTypeID END,
PresenceTypeID = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@PresenceTypeID, PresenceTypeID) ELSE @PresenceTypeID END,
LocationID = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@LocationID, LocationID) ELSE @LocationID END,
EntryLocationID = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@EntryLocationID, EntryLocationID) ELSE @EntryLocationID END,
ExitLocationID = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@ExitLocationID, ExitLocationID) ELSE @ExitLocationID END,
AppearanceNotes = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@AppearanceNotes, AppearanceNotes) ELSE @AppearanceNotes END,
OutfitDescription = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@OutfitDescription, OutfitDescription) ELSE @OutfitDescription END,
PhysicalCondition = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@PhysicalCondition, PhysicalCondition) ELSE @PhysicalCondition END,
EmotionalState = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@EmotionalState, EmotionalState) ELSE @EmotionalState END,
KnowledgeNotes = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@KnowledgeNotes, KnowledgeNotes) ELSE @KnowledgeNotes END,
MatchSource = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@MatchSource, MatchSource) ELSE @MatchSource END,
MatchedText = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@MatchedText, MatchedText) ELSE @MatchedText END,
OccurrenceCount = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@OccurrenceCount, OccurrenceCount) ELSE @OccurrenceCount END,
FirstOccurrenceIndex = CASE WHEN @PreserveExistingWhenNull = 1 THEN COALESCE(@FirstOccurrenceIndex, FirstOccurrenceIndex) ELSE @FirstOccurrenceIndex END,
UpdatedDate = SYSUTCDATETIME()
WHERE SceneCharacterID = @SceneCharacterID;
END
SELECT @SceneCharacterID;
END;
GO

View File

@ -1,25 +1,29 @@
@model ManuscriptScanReviewViewModel @model ManuscriptScanReviewViewModel
@{ @{
ViewData["Title"] = "Review manuscript scan"; ViewData["Title"] = "Review manuscript scan";
var alreadyMatchedCharacters = Model.CharacterCandidates
.Where(candidate => candidate.IsExistingCharacterMatch)
.ToList();
var probableCharacters = Model.CharacterCandidates var probableCharacters = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase)) .Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase))
.ToList(); .ToList();
var possibleCharacters = Model.CharacterCandidates var possibleCharacters = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase)) .Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase))
.ToList(); .ToList();
var relationshipTitles = Model.CharacterCandidates var relationshipTitles = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase)) .Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase))
.ToList(); .ToList();
var primaryCharacters = probableCharacters var primaryCharacters = probableCharacters
.Concat(possibleCharacters) .Concat(possibleCharacters)
.Concat(relationshipTitles) .Concat(relationshipTitles)
.Concat(alreadyMatchedCharacters)
.OrderBy(candidate => candidate.ReviewName) .OrderBy(candidate => candidate.ReviewName)
.ThenBy(candidate => candidate.Name) .ThenBy(candidate => candidate.Name)
.ToList(); .ToList();
var detectedAliasTargets = primaryCharacters var detectedAliasTargets = primaryCharacters
.OrderBy(candidate => candidate.ReviewName) .OrderBy(candidate => candidate.ReviewName)
.ToList(); .ToList();
var visibleCharacterCount = probableCharacters.Count + possibleCharacters.Count + relationshipTitles.Count; var visibleCharacterCount = probableCharacters.Count + possibleCharacters.Count + relationshipTitles.Count + alreadyMatchedCharacters.Count;
var isCharacterStep = string.Equals(ViewData["ScanReviewStep"] as string, "characters", StringComparison.OrdinalIgnoreCase); var isCharacterStep = string.Equals(ViewData["ScanReviewStep"] as string, "characters", StringComparison.OrdinalIgnoreCase);
const int characterPageSize = 10; const int characterPageSize = 10;
var primaryCharacterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacters.Count / (double)characterPageSize)); var primaryCharacterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacters.Count / (double)characterPageSize));
@ -214,7 +218,7 @@
<div class="onboarding-character-step-summary"> <div class="onboarding-character-step-summary">
<div> <div>
<span>Primary candidates</span> <span>Detected names</span>
<strong>@visibleCharacterCount.ToString("N0")</strong> <strong>@visibleCharacterCount.ToString("N0")</strong>
</div> </div>
<div> <div>
@ -237,9 +241,10 @@
<div class="onboarding-character-groups"> <div class="onboarding-character-groups">
@foreach (var group in new[] @foreach (var group in new[]
{ {
new { Title = "Probable characters", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase)).ToList() }, new { Title = "Already matched known characters", Candidates = currentCharacters.Where(candidate => candidate.IsExistingCharacterMatch).ToList() },
new { Title = "Possible characters", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase)).ToList() }, new { Title = "Likely new characters", Candidates = currentCharacters.Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase)).ToList() },
new { Title = "Relationship titles", Candidates = currentCharacters.Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase)).ToList() } new { Title = "Possible new names or aliases", Candidates = currentCharacters.Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase)).ToList() },
new { Title = "Generic or ambiguous references", Candidates = currentCharacters.Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase)).ToList() }
}) })
{ {
if (!group.Candidates.Any()) if (!group.Candidates.Any())