Fix Story Intelligence character review context

This commit is contained in:
Nick Beckley 2026-08-27 18:37:10 +00:00
parent dabf63d91d
commit 7d33323de7
7 changed files with 282 additions and 47 deletions

View File

@ -112,9 +112,13 @@ var tests = new (string Name, Action Test)[]
("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures), ("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures),
("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired), ("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired),
("Name Intelligence library defaults to all locales", NameIntelligenceLibraryDefaultsToAllLocales), ("Name Intelligence library defaults to all locales", NameIntelligenceLibraryDefaultsToAllLocales),
("Story Intelligence new-format context uses canonical characters", StoryIntelligenceNewFormatContextUsesCanonicalCharacters),
("Story Intelligence character defaults link proposed existing matches", StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches), ("Story Intelligence character defaults link proposed existing matches", StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches),
("Story Intelligence character defaults ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences), ("Story Intelligence character defaults ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences),
("Story Intelligence character defaults keep descriptive identities", StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities), ("Story Intelligence character defaults keep descriptive identities", StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities),
("Story Intelligence alias dropdown uses canonical characters", StoryIntelligenceAliasDropdownUsesCanonicalCharacters),
("Story Intelligence review omits character metrics dashboard", StoryIntelligenceReviewOmitsCharacterMetricsDashboard),
("Story Intelligence resumed chapters use canonical labels", StoryIntelligenceResumedChaptersUseCanonicalLabels),
("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch), ("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch),
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults), ("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
("Story Intelligence character review reuses structured appearance aliases", StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases), ("Story Intelligence character review reuses structured appearance aliases", StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases),
@ -3018,6 +3022,23 @@ static void NameIntelligenceLibraryDefaultsToAllLocales()
Assert(!migration.Contains("COALESCE(@LocaleCode, N'en-US')", StringComparison.Ordinal), "Name search procedure must not convert null locale back to en-US."); Assert(!migration.Contains("COALESCE(@LocaleCode, N'en-US')", StringComparison.Ordinal), "Name search procedure must not convert null locale back to en-US.");
} }
static void StoryIntelligenceNewFormatContextUsesCanonicalCharacters()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var model = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs"));
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
Assert(service.Contains("BuildCanonicalKnownCharacterContextJsonAsync(preview, review, build)", StringComparison.Ordinal), "New-format Story Intelligence should build canonical known-character context.");
Assert(service.Contains("characters.ListCharactersAsync(preview.ProjectID)", StringComparison.Ordinal), "Canonical context should include persisted project characters.");
Assert(service.Contains("characters.ListAliasesAsync(character.CharacterID)", StringComparison.Ordinal), "Canonical context should include persisted character aliases.");
Assert(service.Contains("characters.ListSceneCharactersAsync(sceneId)", StringComparison.Ordinal), "Canonical context should include deterministic scene-character mappings.");
Assert(service.Contains("Source = \"CanonicalCoreImport\"", StringComparison.Ordinal), "New-format known-character context should be marked as canonical Core Import context.");
Assert(service.Contains("return BuildKnownCharacterContextJson(preview, review);", StringComparison.Ordinal), "Legacy scan-derived known-character context should remain fallback-only.");
Assert(model.Contains("public IReadOnlyList<int> SceneIDs", StringComparison.Ordinal), "Known-character context should carry mapped canonical scene IDs.");
Assert(prompt.Contains("do not create a character solely because it appears in `knownCharacters`", StringComparison.Ordinal), "Scene prompt should continue preventing known-character-only creation.");
}
static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches() static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches()
{ {
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting, Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting,
@ -3032,6 +3053,10 @@ static void StoryIntelligenceCharacterDefaultsIgnoreGenericReferences()
"Family-role references should default to Ignore."); "Family-role references should default to Ignore.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("One of his mates", null) == StoryIntelligenceCharacterImportActions.Ignore, Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("One of his mates", null) == StoryIntelligenceCharacterImportActions.Ignore,
"Contextual indefinite references should default to Ignore."); "Contextual indefinite references should default to Ignore.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("creepy guy", null, StoryIntelligenceCharacterClassifications.GenericReference) == StoryIntelligenceCharacterImportActions.Ignore,
"Generic classifications should default to Ignore even when the label is descriptive.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("toddler/boy", null, StoryIntelligenceCharacterClassifications.NonCharacter) == StoryIntelligenceCharacterImportActions.Ignore,
"Non-character classifications should default to Ignore.");
} }
static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities() static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities()
@ -3044,6 +3069,48 @@ static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities()
"Named new characters without an existing match should default to Create New."); "Named new characters without an existing match should default to Create New.");
} }
static void StoryIntelligenceAliasDropdownUsesCanonicalCharacters()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml"));
Assert(model.Contains("AliasTargetOptions", StringComparison.Ordinal), "Character review should expose canonical alias targets.");
Assert(model.Contains("public int? AliasTargetCharacterID", StringComparison.Ordinal), "Alias submissions should post canonical CharacterID targets.");
Assert(service.Contains("AliasTargetOptions = existingOptions", StringComparison.Ordinal), "Alias target options should come from canonical existing characters.");
Assert(service.Contains(".OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal), "Canonical alias targets should be alphabetically ordered.");
Assert(service.Contains("existingIndex.Find(choice.AliasTargetCharacterID)", StringComparison.Ordinal), "Import should resolve alias targets by canonical CharacterID.");
Assert(service.Contains("coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID)", StringComparison.Ordinal), "Character import should rerun deterministic scene-character mapping after accepted aliases or new characters.");
Assert(view.Contains("name=\"Characters[@i].AliasTargetCharacterID\"", StringComparison.Ordinal), "Alias dropdown should post canonical CharacterID values.");
Assert(view.Contains("Model.CharacterReview.AliasTargetOptions", StringComparison.Ordinal), "Alias dropdown should render canonical targets, not unresolved candidates.");
Assert(!view.Contains("Model.CharacterReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key", StringComparison.Ordinal), "Alias dropdown must not be populated from unresolved candidates.");
}
static void StoryIntelligenceReviewOmitsCharacterMetricsDashboard()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
Assert(!view.Contains("_StoryIntelligencePipelineSummary", StringComparison.Ordinal), "Character Review should not render the large cross-stage metrics dashboard.");
Assert(view.Contains("onboarding-compact-status", StringComparison.Ordinal), "Character Review should keep a compact decision summary.");
Assert(model.Contains("GenericReferenceCount", StringComparison.Ordinal), "Compact summary should include useful candidate classification counts.");
Assert(view.IndexOf("story-character-card-grid", StringComparison.Ordinal) > view.IndexOf("onboarding-compact-status", StringComparison.Ordinal), "Candidate cards should appear directly after compact context.");
}
static void StoryIntelligenceResumedChaptersUseCanonicalLabels()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
Assert(service.Contains("chapters.ListByBookAsync(bookId)", StringComparison.Ordinal), "Resume should load canonical chapters for the book.");
Assert(service.Contains("CanonicalChapterTitle(chapterLookup.GetValueOrDefault(run.ChapterID)", StringComparison.Ordinal), "Committed runs should display canonical chapter titles.");
Assert(service.Contains("CanonicalChapterTitle(run.ChapterID.HasValue ? chapterLookup.GetValueOrDefault(run.ChapterID.Value) : null, run.ChapterNumber)", StringComparison.Ordinal), "Active runs should display canonical chapter titles.");
Assert(!service.Contains("FirstConfigured(run.SourceFileName, run.CurrentMessage", StringComparison.Ordinal), "Resume should never prefer Word document filenames as chapter titles.");
Assert(service.Contains("return title.StartsWith(\"Chapter \", StringComparison.OrdinalIgnoreCase) ? title : $\"{fallback} - {title}\"", StringComparison.Ordinal), "Blank titles should fall back to Chapter N and nonblank titles should include chapter number.");
}
static void StoryIntelligenceCharacterReviewPostsSelectedExistingMatch() static void StoryIntelligenceCharacterReviewPostsSelectedExistingMatch()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -177,6 +177,7 @@ public sealed class StoryIntelligenceKnownCharacterContext
public string SexHint { get; init; } = "Unknown"; public string SexHint { get; init; } = "Unknown";
public int? ExistingCharacterID { get; init; } public int? ExistingCharacterID { get; init; }
public IReadOnlyList<string> CandidateKeys { get; init; } = []; public IReadOnlyList<string> CandidateKeys { get; init; } = [];
public IReadOnlyList<int> SceneIDs { get; init; } = [];
public int MentionCount { get; init; } public int MentionCount { get; init; }
public decimal Confidence { get; init; } public decimal Confidence { get; init; }
public string Source { get; init; } = "InitialManuscriptScan"; public string Source { get; init; } = "InitialManuscriptScan";

View File

@ -46,6 +46,8 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
IStoryMemoryRepository storyMemory, IStoryMemoryRepository storyMemory,
IBookRepository books, IBookRepository books,
IChapterRepository chapters,
ICharacterRepository characters,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore, IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier, IStoryIntelligenceProgressNotifier notifier,
@ -131,7 +133,7 @@ public sealed class OnboardingStoryIntelligenceService(
clientStatus.ChapterStructureModel, clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel); clientStatus.SceneIntelligenceModel);
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>(); var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
var knownCharactersJson = BuildKnownCharacterContextJson(preview, review); var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(preview, review, build);
foreach (var chapter in included) foreach (var chapter in included)
{ {
@ -158,6 +160,7 @@ public sealed class OnboardingStoryIntelligenceService(
} }
var paragraphs = StoryIntelligenceParagraphs.Split(text); var paragraphs = StoryIntelligenceParagraphs.Split(text);
var canonicalChapterTitle = CanonicalChapterTitle(await chapters.GetAsync(chapterId), chapter.Decision.ChapterNumber);
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{ {
UserID = userId, UserID = userId,
@ -166,7 +169,7 @@ public sealed class OnboardingStoryIntelligenceService(
ChapterID = chapterId, ChapterID = chapterId,
ChapterNumber = chapter.Decision.ChapterNumber, ChapterNumber = chapter.Decision.ChapterNumber,
SourceType = SourceType, SourceType = SourceType,
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}", SourceLabel = $"{wizard.SelectedBookTitle}: {canonicalChapterTitle}",
ChapterText = text, ChapterText = text,
SourceFileName = preview.DocumentTitle, SourceFileName = preview.DocumentTitle,
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text), SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
@ -182,7 +185,7 @@ public sealed class OnboardingStoryIntelligenceService(
{ {
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey, TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
ChapterNumber = chapter.Decision.ChapterNumber, ChapterNumber = chapter.Decision.ChapterNumber,
ChapterTitle = chapter.Decision.Title, ChapterTitle = canonicalChapterTitle,
ChapterID = chapterId, ChapterID = chapterId,
RunID = runId RunID = runId
}); });
@ -266,6 +269,68 @@ public sealed class OnboardingStoryIntelligenceService(
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)]; return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)];
} }
private async Task<string?> BuildCanonicalKnownCharacterContextJsonAsync(
ManuscriptScanPreview preview,
ManuscriptScanReviewDecision review,
OnboardingManuscriptBuildResult build)
{
if (build.SceneMappings.Count == 0)
{
return BuildKnownCharacterContextJson(preview, review);
}
var allCharacters = (await characters.ListCharactersAsync(preview.ProjectID))
.Where(character => !character.IsArchived)
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
if (allCharacters.Count == 0)
{
return BuildKnownCharacterContextJson(preview, review);
}
var sceneIds = build.SceneMappings.Select(mapping => mapping.SceneID).Distinct().ToList();
var sceneIdsByCharacter = new Dictionary<int, HashSet<int>>();
foreach (var sceneId in sceneIds)
{
foreach (var sceneCharacter in await characters.ListSceneCharactersAsync(sceneId))
{
if (!sceneIdsByCharacter.TryGetValue(sceneCharacter.CharacterID, out var mappedScenes))
{
mappedScenes = [];
sceneIdsByCharacter[sceneCharacter.CharacterID] = mappedScenes;
}
mappedScenes.Add(sceneId);
}
}
var context = new List<StoryIntelligenceKnownCharacterContext>();
foreach (var character in allCharacters)
{
var aliases = (await characters.ListAliasesAsync(character.CharacterID))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase)
.ToList();
var mappedScenes = sceneIdsByCharacter.GetValueOrDefault(character.CharacterID)?.OrderBy(id => id).ToList() ?? [];
context.Add(new StoryIntelligenceKnownCharacterContext
{
CanonicalName = character.CharacterName,
Aliases = aliases,
SexHint = CharacterSexHintService.Normalise(character.Sex, character.CharacterName),
ExistingCharacterID = character.CharacterID,
CandidateKeys = [],
SceneIDs = mappedScenes,
MentionCount = mappedScenes.Count,
Confidence = mappedScenes.Count > 0 ? 1m : 0.8m,
Source = "CanonicalCoreImport"
});
}
return JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
public static string? BuildKnownCharacterContextJson(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review) public static string? BuildKnownCharacterContextJson(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
{ {
var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal); var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
@ -755,6 +820,7 @@ public sealed class OnboardingStoryIntelligenceService(
return null; return null;
} }
var chapterLookup = (await chapters.ListByBookAsync(bookId)).ToDictionary(chapter => chapter.ChapterID);
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId)) var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
.OrderBy(run => run.ChapterNumber) .OrderBy(run => run.ChapterNumber)
.ThenBy(run => run.StoryIntelligenceRunID) .ThenBy(run => run.StoryIntelligenceRunID)
@ -762,7 +828,7 @@ public sealed class OnboardingStoryIntelligenceService(
{ {
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}", TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber), ChapterNumber = Convert.ToInt32(run.ChapterNumber),
ChapterTitle = run.ChapterTitle, ChapterTitle = CanonicalChapterTitle(chapterLookup.GetValueOrDefault(run.ChapterID), run.ChapterNumber),
ChapterID = run.ChapterID, ChapterID = run.ChapterID,
RunID = run.StoryIntelligenceRunID RunID = run.StoryIntelligenceRunID
}) })
@ -777,7 +843,7 @@ public sealed class OnboardingStoryIntelligenceService(
{ {
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}", TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0), ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
ChapterTitle = FirstConfigured(run.SourceFileName, run.CurrentMessage, run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : $"Run {run.StoryIntelligenceRunID:N0}")!, ChapterTitle = CanonicalChapterTitle(run.ChapterID.HasValue ? chapterLookup.GetValueOrDefault(run.ChapterID.Value) : null, run.ChapterNumber),
ChapterID = run.ChapterID ?? 0, ChapterID = run.ChapterID ?? 0,
RunID = run.StoryIntelligenceRunID RunID = run.StoryIntelligenceRunID
}) })
@ -909,6 +975,19 @@ public sealed class OnboardingStoryIntelligenceService(
private int RequireUserId() private int RequireUserId()
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in before using Story Intelligence."); => currentUser.UserId ?? throw new InvalidOperationException("Sign in before using Story Intelligence.");
private static string CanonicalChapterTitle(Chapter? chapter, decimal? chapterNumber)
{
var number = chapterNumber ?? chapter?.ChapterNumber;
var fallback = number.HasValue && number.Value > 0 ? $"Chapter {number.Value:0.##}" : "Chapter";
var title = CleanContext(chapter?.ChapterTitle).TrimStart('-', ' ', '\t');
if (string.IsNullOrWhiteSpace(title))
{
return fallback;
}
return title.StartsWith("Chapter ", StringComparison.OrdinalIgnoreCase) ? title : $"{fallback} - {title}";
}
private static IReadOnlyList<IncludedChapter> IncludedChapters(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review) private static IReadOnlyList<IncludedChapter> IncludedChapters(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
{ {
var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal); var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);

View File

@ -18,6 +18,7 @@ public sealed class StoryIntelligenceCharacterImportService(
ISceneRepository scenes, ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
INameIntelligenceService nameIntelligence, INameIntelligenceService nameIntelligence,
ICoreImportCharacterMappingService coreImportCharacterMapping,
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@ -79,7 +80,10 @@ public sealed class StoryIntelligenceCharacterImportService(
CharacterID = character.CharacterID, CharacterID = character.CharacterID,
CharacterName = character.CharacterName CharacterName = character.CharacterName
}) })
.OrderBy(character => character.CharacterName) .OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
var classified = visibleCandidates
.Select(candidate => new { Candidate = candidate, Classification = ClassifyCandidate(candidate) })
.ToList(); .ToList();
return new StoryIntelligenceCharacterReviewViewModel return new StoryIntelligenceCharacterReviewViewModel
{ {
@ -87,21 +91,27 @@ public sealed class StoryIntelligenceCharacterImportService(
CanImport = visibleCandidates.Count > 0, CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount, AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel GenericReferenceCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.GenericReference),
ExistingMatchCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.ExistingCharacter),
NewCandidateCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.NewCharacter || item.Classification == StoryIntelligenceCharacterClassifications.NewAlias),
AmbiguousCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.Ambiguous),
AliasTargetOptions = existingOptions,
Candidates = classified.Select(item => new StoryIntelligenceCharacterReviewCandidateViewModel
{ {
DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(candidate.DisplayName, candidate.ExistingCharacterID), DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(item.Candidate.DisplayName, item.Candidate.ExistingCharacterID, item.Classification),
Key = candidate.Key, Classification = item.Classification,
CharacterName = candidate.DisplayName, Key = item.Candidate.Key,
ImportName = candidate.DisplayName, CharacterName = item.Candidate.DisplayName,
AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(), ImportName = item.Candidate.DisplayName,
Confidence = ConfidenceBand(candidate.Confidence), AppearsInScenes = item.Candidate.PresentAppearances.Concat(item.Candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(), Confidence = ConfidenceBand(item.Candidate.Confidence),
ExampleFirstAppearance = candidate.FirstAppearanceNote, PossibleAliases = item.Candidate.Aliases.OrderBy(value => value).ToList(),
ExistingCharacterID = candidate.ExistingCharacterID, ExampleFirstAppearance = item.Candidate.FirstAppearanceNote,
ExistingCharacterName = candidate.ExistingCharacterName, ExistingCharacterID = item.Candidate.ExistingCharacterID,
SelectedExistingCharacterID = candidate.ExistingCharacterID, ExistingCharacterName = item.Candidate.ExistingCharacterName,
SelectedExistingCharacterID = item.Candidate.ExistingCharacterID,
ExistingCharacterOptions = existingOptions, ExistingCharacterOptions = existingOptions,
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(candidate.Key) NameIntelligenceWarning = nameWarnings.GetValueOrDefault(item.Candidate.Key)
}).ToList() }).ToList()
}; };
} }
@ -277,29 +287,42 @@ public sealed class StoryIntelligenceCharacterImportService(
continue; continue;
} }
var targetKey = Clean(choice.AliasTargetKey); var target = existingIndex.Find(choice.AliasTargetCharacterID);
if (string.IsNullOrWhiteSpace(targetKey) CharacterCandidate? targetCandidate = null;
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase) var targetCharacterId = target?.CharacterID;
|| !choices.TryGetValue(targetKey, out var targetChoice) var targetName = target?.CharacterName;
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase) if (!targetCharacterId.HasValue)
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase) {
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate) var targetKey = Clean(choice.AliasTargetKey);
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetCharacterId)) if (!string.IsNullOrWhiteSpace(targetKey)
&& !string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
&& choices.TryGetValue(targetKey, out var targetChoice)
&& !string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
&& candidateByKey.TryGetValue(targetKey, out targetCandidate)
&& resolvedCandidateIds.TryGetValue(targetKey, out var resolvedTargetCharacterId))
{
targetCharacterId = resolvedTargetCharacterId;
targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
}
}
if (!targetCharacterId.HasValue)
{ {
logger.LogWarning( logger.LogWarning(
"Story Intelligence character alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.", "Story Intelligence character alias candidate {CandidateKey} could not be imported because no canonical target was selected. AliasTargetCharacterID={AliasTargetCharacterID}",
candidate.Key, candidate.Key,
targetKey); choice.AliasTargetCharacterID);
continue; continue;
} }
var targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName; targetName ??= $"Character {targetCharacterId.Value:N0}";
await TryAddAliasAsync(targetCharacterId, candidate.DisplayName, targetName); await TryAddAliasAsync(targetCharacterId.Value, candidate.DisplayName, targetName);
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId); AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId.Value);
foreach (var alias in candidate.Aliases) foreach (var alias in candidate.Aliases)
{ {
await TryAddAliasAsync(targetCharacterId, alias, targetName); await TryAddAliasAsync(targetCharacterId.Value, alias, targetName);
AddResolvedName(resolvedCharacters, alias, targetCharacterId); AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value);
} }
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked)) foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
@ -307,16 +330,16 @@ public sealed class StoryIntelligenceCharacterImportService(
await characters.SaveSceneCharacterAsync(new SceneCharacter await characters.SaveSceneCharacterAsync(new SceneCharacter
{ {
SceneID = appearance.SceneID, SceneID = appearance.SceneID,
CharacterID = targetCharacterId, CharacterID = targetCharacterId.Value,
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene), RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly), PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetCandidate.DisplayName, appearance.Notes) AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetName, appearance.Notes)
}); });
linkedAppearances++; linkedAppearances++;
} }
aliased++; aliased++;
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId, true); AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId.Value, true);
} }
foreach (var scene in data.SceneAnalyses) foreach (var scene in data.SceneAnalyses)
@ -341,23 +364,26 @@ public sealed class StoryIntelligenceCharacterImportService(
PovLinksResolved = povLinks, PovLinksResolved = povLinks,
ScenePeoplePanelsUpdated = linkedAppearances ScenePeoplePanelsUpdated = linkedAppearances
}; };
var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID);
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID); await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}", "Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks} RemappedScenes={RemappedScenes} RemappedLinks={RemappedLinks}",
batch.BatchID, batch.BatchID,
created, created,
linkedExisting, linkedExisting,
aliased, aliased,
ignored, ignored,
linkedAppearances, linkedAppearances,
povLinks); povLinks,
remap.ScenesScanned,
remap.LinksSaved);
return new StoryIntelligenceImportCommitResult return new StoryIntelligenceImportCommitResult
{ {
Success = true, Success = true,
ScenesCreated = linkedAppearances, ScenesCreated = linkedAppearances,
Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {aliased:N0} alias decision(s) applied, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved." Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {aliased:N0} alias decision(s) applied, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved. Deterministic remapping updated {remap.LinksSaved:N0} link(s)."
}; };
} }
@ -793,6 +819,39 @@ public sealed class StoryIntelligenceCharacterImportService(
return !IsGenericGroupReference(name); return !IsGenericGroupReference(name);
} }
private static string ClassifyCandidate(CharacterCandidate candidate)
{
var name = CleanCharacterName(candidate.DisplayName);
if (!IsCharacterNameCandidate(name))
{
return StoryIntelligenceCharacterClassifications.NonCharacter;
}
if (candidate.ExistingCharacterID.HasValue)
{
return StoryIntelligenceCharacterClassifications.ExistingCharacter;
}
if (StoryIntelligenceCharacterReviewDefaults.IsGenericContextualReference(name)
|| IsFamilyRoleOnly(name)
|| IsGenericGroupReference(name))
{
return StoryIntelligenceCharacterClassifications.GenericReference;
}
if (candidate.Aliases.Count > 0)
{
return StoryIntelligenceCharacterClassifications.NewAlias;
}
if (candidate.Confidence is null or < 0.65m)
{
return StoryIntelligenceCharacterClassifications.Ambiguous;
}
return StoryIntelligenceCharacterClassifications.NewCharacter;
}
private static bool IsCharacterNameCandidate(string? name) private static bool IsCharacterNameCandidate(string? name)
{ {
var clean = CleanCharacterName(name); var clean = CleanCharacterName(name);

View File

@ -13,14 +13,16 @@ public static class StoryIntelligenceCharacterReviewDefaults
@"^(one of (them|his mates|her mates|their mates|the men|the women|the boys|the girls)|his mate|her mate|their mate|his friend|her friend|their friend)$", @"^(one of (them|his mates|her mates|their mates|the men|the women|the boys|the girls)|his mate|her mate|their mate|his friend|her friend|their friend)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled); RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static string DetermineAction(string? candidateName, int? proposedExistingCharacterId) public static string DetermineAction(string? candidateName, int? proposedExistingCharacterId, string? classification = null)
{ {
if (proposedExistingCharacterId.HasValue) if (proposedExistingCharacterId.HasValue)
{ {
return StoryIntelligenceCharacterImportActions.LinkExisting; return StoryIntelligenceCharacterImportActions.LinkExisting;
} }
if (IsGenericContextualReference(candidateName)) if (string.Equals(classification, StoryIntelligenceCharacterClassifications.GenericReference, StringComparison.OrdinalIgnoreCase)
|| string.Equals(classification, StoryIntelligenceCharacterClassifications.NonCharacter, StringComparison.OrdinalIgnoreCase)
|| IsGenericContextualReference(candidateName))
{ {
return StoryIntelligenceCharacterImportActions.Ignore; return StoryIntelligenceCharacterImportActions.Ignore;
} }

View File

@ -273,7 +273,12 @@ public sealed class StoryIntelligenceCharacterReviewViewModel
public bool HasCommittedScenes { get; init; } public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { get; init; } public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; } public bool IsComplete { get; init; }
public int GenericReferenceCount { get; init; }
public int ExistingMatchCount { get; init; }
public int NewCandidateCount { get; init; }
public int AmbiguousCount { get; init; }
public IReadOnlyList<StoryIntelligenceCharacterReviewCandidateViewModel> Candidates { get; init; } = []; public IReadOnlyList<StoryIntelligenceCharacterReviewCandidateViewModel> Candidates { get; init; } = [];
public IReadOnlyList<StoryIntelligenceCharacterExistingOptionViewModel> AliasTargetOptions { get; init; } = [];
} }
public sealed class StoryIntelligenceCharacterReviewCandidateViewModel public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
@ -282,6 +287,7 @@ public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
public string CharacterName { get; init; } = string.Empty; public string CharacterName { get; init; } = string.Empty;
public string ImportName { get; init; } = string.Empty; public string ImportName { get; init; } = string.Empty;
public string DefaultAction { get; init; } = StoryIntelligenceCharacterImportActions.CreateNew; public string DefaultAction { get; init; } = StoryIntelligenceCharacterImportActions.CreateNew;
public string Classification { get; init; } = StoryIntelligenceCharacterClassifications.NewCharacter;
public int AppearsInScenes { get; init; } public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown"; public string Confidence { get; init; } = "Unknown";
public IReadOnlyList<string> PossibleAliases { get; init; } = []; public IReadOnlyList<string> PossibleAliases { get; init; } = [];
@ -314,9 +320,20 @@ public sealed class StoryIntelligenceCharacterImportChoiceForm
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew; public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew;
public string? ImportName { get; set; } public string? ImportName { get; set; }
public int? ExistingCharacterID { get; set; } public int? ExistingCharacterID { get; set; }
public int? AliasTargetCharacterID { get; set; }
public string? AliasTargetKey { get; set; } public string? AliasTargetKey { get; set; }
} }
public static class StoryIntelligenceCharacterClassifications
{
public const string ExistingCharacter = "ExistingCharacter";
public const string NewAlias = "NewAlias";
public const string NewCharacter = "NewCharacter";
public const string GenericReference = "GenericReference";
public const string Ambiguous = "Ambiguous";
public const string NonCharacter = "NonCharacter";
}
public static class StoryIntelligenceCharacterImportActions public static class StoryIntelligenceCharacterImportActions
{ {
public const string CreateNew = "CreateNew"; public const string CreateNew = "CreateNew";

View File

@ -23,7 +23,13 @@
<div class="alert alert-success">@message</div> <div class="alert alert-success">@message</div>
} }
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" /> <p class="onboarding-compact-status">
@Model.CharacterReview.Candidates.Count.ToString("N0") candidate@(Model.CharacterReview.Candidates.Count == 1 ? string.Empty : "s")
&middot; @Model.CharacterReview.ExistingMatchCount.ToString("N0") existing match@(Model.CharacterReview.ExistingMatchCount == 1 ? string.Empty : "es")
&middot; @Model.CharacterReview.NewCandidateCount.ToString("N0") new or alias
&middot; @Model.CharacterReview.AmbiguousCount.ToString("N0") ambiguous
&middot; @Model.CharacterReview.GenericReferenceCount.ToString("N0") generic
</p>
<section class="story-review-chapter-list" aria-label="Character review"> <section class="story-review-chapter-list" aria-label="Character review">
@if (Model.CharacterReview.Candidates.Count == 0) @if (Model.CharacterReview.Candidates.Count == 0)
@ -63,7 +69,7 @@
<summary> <summary>
<span> <span>
<strong>@candidate.CharacterName</strong> <strong>@candidate.CharacterName</strong>
<small>@candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small> <small>@candidate.Classification &middot; @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
</span> </span>
<em>@candidate.Confidence</em> <em>@candidate.Confidence</em>
</summary> </summary>
@ -134,11 +140,11 @@
<div data-character-alias-panel hidden> <div data-character-alias-panel hidden>
<label class="form-label" for="character-alias-target-@i">Alias target</label> <label class="form-label" for="character-alias-target-@i">Alias target</label>
<select id="character-alias-target-@i" class="form-select" name="Characters[@i].AliasTargetKey" data-character-alias-target> <select id="character-alias-target-@i" class="form-select" name="Characters[@i].AliasTargetCharacterID" data-character-alias-target>
<option value="">Choose character...</option> <option value="">Choose character...</option>
@foreach (var target in Model.CharacterReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase))) @foreach (var target in Model.CharacterReview.AliasTargetOptions)
{ {
<option value="@target.Key">@target.CharacterName@(target.IsExistingMatch ? $" -> {target.ExistingCharacterName}" : string.Empty)</option> <option value="@target.CharacterID" data-canonical="true">@target.CharacterName</option>
} }
</select> </select>
</div> </div>
@ -187,6 +193,10 @@
const previous = select.value; const previous = select.value;
for (const option of Array.from(select.options)) { for (const option of Array.from(select.options)) {
if (!option.value) continue; if (!option.value) continue;
if (option.dataset.canonical === "true") {
option.disabled = false;
continue;
}
const targetCard = allCards.find(item => item.querySelector("[data-character-key]")?.getAttribute("data-character-key") === option.value); const targetCard = allCards.find(item => item.querySelector("[data-character-key]")?.getAttribute("data-character-key") === option.value);
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard); option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
} }