From 7d33323de776870689cbde0e390c1dfbb1a0803f Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Thu, 27 Aug 2026 18:37:10 +0000 Subject: [PATCH] Fix Story Intelligence character review context --- PlotLine.Tests/Program.cs | 67 +++++++++ .../StoryIntelligencePersistenceModels.cs | 1 + .../OnboardingStoryIntelligenceService.cs | 89 +++++++++++- ...StoryIntelligenceCharacterImportService.cs | 129 +++++++++++++----- ...toryIntelligenceCharacterReviewDefaults.cs | 6 +- PlotLine/ViewModels/OnboardingViewModels.cs | 17 +++ .../StoryIntelligenceCharacters.cshtml | 20 ++- 7 files changed, 282 insertions(+), 47 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 66c32cb..25bc992 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -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 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"); diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index f08f697..0c94c2d 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -177,6 +177,7 @@ public sealed class StoryIntelligenceKnownCharacterContext public string SexHint { get; init; } = "Unknown"; public int? ExistingCharacterID { get; init; } public IReadOnlyList CandidateKeys { get; init; } = []; + public IReadOnlyList SceneIDs { get; init; } = []; public int MentionCount { get; init; } public decimal Confidence { get; init; } public string Source { get; init; } = "InitialManuscriptScan"; diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 22fe7ee..e07766a 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -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(); - 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 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>(); + 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(); + 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 IncludedChapters(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review) { var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal); diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index e2540d9..fff10d0 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -18,6 +18,7 @@ public sealed class StoryIntelligenceCharacterImportService( ISceneRepository scenes, IStoryIntelligencePipelineStateService pipelineState, INameIntelligenceService nameIntelligence, + ICoreImportCharacterMappingService coreImportCharacterMapping, ILogger 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); diff --git a/PlotLine/Services/StoryIntelligenceCharacterReviewDefaults.cs b/PlotLine/Services/StoryIntelligenceCharacterReviewDefaults.cs index 835585f..ea3ff88 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterReviewDefaults.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterReviewDefaults.cs @@ -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; } diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 93a8eba..dc2aca8 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -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 Candidates { get; init; } = []; + public IReadOnlyList 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 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"; diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml index 2abc139..81ebb3b 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml @@ -23,7 +23,13 @@
@message
} - +

+ @Model.CharacterReview.Candidates.Count.ToString("N0") candidate@(Model.CharacterReview.Candidates.Count == 1 ? string.Empty : "s") + · @Model.CharacterReview.ExistingMatchCount.ToString("N0") existing match@(Model.CharacterReview.ExistingMatchCount == 1 ? string.Empty : "es") + · @Model.CharacterReview.NewCandidateCount.ToString("N0") new or alias + · @Model.CharacterReview.AmbiguousCount.ToString("N0") ambiguous + · @Model.CharacterReview.GenericReferenceCount.ToString("N0") generic +

@if (Model.CharacterReview.Candidates.Count == 0) @@ -63,7 +69,7 @@ @candidate.CharacterName - @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s") + @candidate.Classification · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s") @candidate.Confidence @@ -134,11 +140,11 @@ @@ -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); }