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 review warning is wired", NameIntelligenceReviewWarningIsWired),
("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 ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences),
("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 default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
("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.");
}
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()
{
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting,
@ -3032,6 +3053,10 @@ static void StoryIntelligenceCharacterDefaultsIgnoreGenericReferences()
"Family-role references should default to Ignore.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("One of his mates", null) == StoryIntelligenceCharacterImportActions.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()
@ -3044,6 +3069,48 @@ static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities()
"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()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

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

View File

@ -46,6 +46,8 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligencePipelineStateService pipelineState,
IStoryMemoryRepository storyMemory,
IBookRepository books,
IChapterRepository chapters,
ICharacterRepository characters,
IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier,
@ -131,7 +133,7 @@ public sealed class OnboardingStoryIntelligenceService(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel);
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
var knownCharactersJson = BuildKnownCharacterContextJson(preview, review);
var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(preview, review, build);
foreach (var chapter in included)
{
@ -158,6 +160,7 @@ public sealed class OnboardingStoryIntelligenceService(
}
var paragraphs = StoryIntelligenceParagraphs.Split(text);
var canonicalChapterTitle = CanonicalChapterTitle(await chapters.GetAsync(chapterId), chapter.Decision.ChapterNumber);
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{
UserID = userId,
@ -166,7 +169,7 @@ public sealed class OnboardingStoryIntelligenceService(
ChapterID = chapterId,
ChapterNumber = chapter.Decision.ChapterNumber,
SourceType = SourceType,
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}",
SourceLabel = $"{wizard.SelectedBookTitle}: {canonicalChapterTitle}",
ChapterText = text,
SourceFileName = preview.DocumentTitle,
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
@ -182,7 +185,7 @@ public sealed class OnboardingStoryIntelligenceService(
{
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
ChapterNumber = chapter.Decision.ChapterNumber,
ChapterTitle = chapter.Decision.Title,
ChapterTitle = canonicalChapterTitle,
ChapterID = chapterId,
RunID = runId
});
@ -266,6 +269,68 @@ public sealed class OnboardingStoryIntelligenceService(
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)
{
var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
@ -755,6 +820,7 @@ public sealed class OnboardingStoryIntelligenceService(
return null;
}
var chapterLookup = (await chapters.ListByBookAsync(bookId)).ToDictionary(chapter => chapter.ChapterID);
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
.OrderBy(run => run.ChapterNumber)
.ThenBy(run => run.StoryIntelligenceRunID)
@ -762,7 +828,7 @@ public sealed class OnboardingStoryIntelligenceService(
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
ChapterTitle = run.ChapterTitle,
ChapterTitle = CanonicalChapterTitle(chapterLookup.GetValueOrDefault(run.ChapterID), run.ChapterNumber),
ChapterID = run.ChapterID,
RunID = run.StoryIntelligenceRunID
})
@ -777,7 +843,7 @@ public sealed class OnboardingStoryIntelligenceService(
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
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,
RunID = run.StoryIntelligenceRunID
})
@ -909,6 +975,19 @@ public sealed class OnboardingStoryIntelligenceService(
private int RequireUserId()
=> 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)
{
var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);

View File

@ -18,6 +18,7 @@ public sealed class StoryIntelligenceCharacterImportService(
ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
INameIntelligenceService nameIntelligence,
ICoreImportCharacterMappingService coreImportCharacterMapping,
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
@ -79,7 +80,10 @@ public sealed class StoryIntelligenceCharacterImportService(
CharacterID = character.CharacterID,
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();
return new StoryIntelligenceCharacterReviewViewModel
{
@ -87,21 +91,27 @@ public sealed class StoryIntelligenceCharacterImportService(
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
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),
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,
SelectedExistingCharacterID = candidate.ExistingCharacterID,
DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(item.Candidate.DisplayName, item.Candidate.ExistingCharacterID, item.Classification),
Classification = item.Classification,
Key = item.Candidate.Key,
CharacterName = item.Candidate.DisplayName,
ImportName = item.Candidate.DisplayName,
AppearsInScenes = item.Candidate.PresentAppearances.Concat(item.Candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(item.Candidate.Confidence),
PossibleAliases = item.Candidate.Aliases.OrderBy(value => value).ToList(),
ExampleFirstAppearance = item.Candidate.FirstAppearanceNote,
ExistingCharacterID = item.Candidate.ExistingCharacterID,
ExistingCharacterName = item.Candidate.ExistingCharacterName,
SelectedExistingCharacterID = item.Candidate.ExistingCharacterID,
ExistingCharacterOptions = existingOptions,
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(candidate.Key)
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(item.Candidate.Key)
}).ToList()
};
}
@ -277,29 +287,42 @@ public sealed class StoryIntelligenceCharacterImportService(
continue;
}
var targetKey = Clean(choice.AliasTargetKey);
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 var targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetCharacterId))
var target = existingIndex.Find(choice.AliasTargetCharacterID);
CharacterCandidate? targetCandidate = null;
var targetCharacterId = target?.CharacterID;
var targetName = target?.CharacterName;
if (!targetCharacterId.HasValue)
{
var targetKey = Clean(choice.AliasTargetKey);
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(
"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,
targetKey);
choice.AliasTargetCharacterID);
continue;
}
var targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
await TryAddAliasAsync(targetCharacterId, candidate.DisplayName, targetName);
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId);
targetName ??= $"Character {targetCharacterId.Value:N0}";
await TryAddAliasAsync(targetCharacterId.Value, candidate.DisplayName, targetName);
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId.Value);
foreach (var alias in candidate.Aliases)
{
await TryAddAliasAsync(targetCharacterId, alias, targetName);
AddResolvedName(resolvedCharacters, alias, targetCharacterId);
await TryAddAliasAsync(targetCharacterId.Value, alias, targetName);
AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value);
}
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
{
SceneID = appearance.SceneID,
CharacterID = targetCharacterId,
CharacterID = targetCharacterId.Value,
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetCandidate.DisplayName, appearance.Notes)
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetName, appearance.Notes)
});
linkedAppearances++;
}
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)
@ -341,23 +364,26 @@ public sealed class StoryIntelligenceCharacterImportService(
PovLinksResolved = povLinks,
ScenePeoplePanelsUpdated = linkedAppearances
};
var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID);
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
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,
created,
linkedExisting,
aliased,
ignored,
linkedAppearances,
povLinks);
povLinks,
remap.ScenesScanned,
remap.LinksSaved);
return new StoryIntelligenceImportCommitResult
{
Success = true,
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);
}
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)
{
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)$",
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)
{
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;
}

View File

@ -273,7 +273,12 @@ public sealed class StoryIntelligenceCharacterReviewViewModel
public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { 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<StoryIntelligenceCharacterExistingOptionViewModel> AliasTargetOptions { get; init; } = [];
}
public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
@ -282,6 +287,7 @@ public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
public string CharacterName { get; init; } = string.Empty;
public string ImportName { get; init; } = string.Empty;
public string DefaultAction { get; init; } = StoryIntelligenceCharacterImportActions.CreateNew;
public string Classification { get; init; } = StoryIntelligenceCharacterClassifications.NewCharacter;
public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown";
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
@ -314,9 +320,20 @@ public sealed class StoryIntelligenceCharacterImportChoiceForm
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew;
public string? ImportName { get; set; }
public int? ExistingCharacterID { get; set; }
public int? AliasTargetCharacterID { 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 const string CreateNew = "CreateNew";

View File

@ -23,7 +23,13 @@
<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">
@if (Model.CharacterReview.Candidates.Count == 0)
@ -63,7 +69,7 @@
<summary>
<span>
<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>
<em>@candidate.Confidence</em>
</summary>
@ -134,11 +140,11 @@
<div data-character-alias-panel hidden>
<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>
@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>
</div>
@ -187,6 +193,10 @@
const previous = select.value;
for (const option of Array.from(select.options)) {
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);
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
}