diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index a3ec96e..7f7c678 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -74,6 +74,12 @@ var tests = new (string Name, Action Test)[] ("Name Intelligence reference data is copied on publish", NameIntelligenceReferenceDataIsCopiedOnPublish), ("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures), ("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired), + ("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 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), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions), ("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation), @@ -1926,6 +1932,67 @@ static void NameIntelligenceReviewWarningIsWired() Assert(view.Contains("Name warning", StringComparison.Ordinal), "Character review view should render the name warning."); } +static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches() +{ + Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting, + "A proposed existing character match should default to Link Existing."); + Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Mum", 42) == StoryIntelligenceCharacterImportActions.LinkExisting, + "An explicit/proposed existing character match should beat the generic reference heuristic."); +} + +static void StoryIntelligenceCharacterDefaultsIgnoreGenericReferences() +{ + Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Mum", null) == StoryIntelligenceCharacterImportActions.Ignore, + "Family-role references should default to Ignore."); + Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("One of his mates", null) == StoryIntelligenceCharacterImportActions.Ignore, + "Contextual indefinite references should default to Ignore."); +} + +static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities() +{ + Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Creepy guy", null) == StoryIntelligenceCharacterImportActions.CreateNew, + "Specific descriptive identities should remain create-new candidates."); + Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Guy who gropes narrator", null) == StoryIntelligenceCharacterImportActions.CreateNew, + "Specific descriptive identities should not be treated as generic group references."); + Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Zoe", null) == StoryIntelligenceCharacterImportActions.CreateNew, + "Named new characters without an existing match should default to Create New."); +} + +static void StoryIntelligenceCharacterReviewPostsSelectedExistingMatch() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml")); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); + var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs")); + + Assert(model.Contains("DefaultAction", StringComparison.Ordinal), "Character review candidates should carry a server-side default action."); + Assert(model.Contains("SelectedExistingCharacterID", StringComparison.Ordinal), "Character review candidates should carry the selected existing match."); + Assert(view.Contains("name=\"Characters[@i].ExistingCharacterID\"", StringComparison.Ordinal), "Review form should post the selected existing character."); + Assert(view.Contains("data-character-existing-panel", StringComparison.Ordinal), "Review form should render an existing-character dropdown."); + Assert(service.Contains("existingIndex.Find(choice.ExistingCharacterID)", StringComparison.Ordinal), "Import should use the posted existing-character selection."); +} + +static void StoryIntelligenceCharacterDefaultChangesPreservePersistedResults() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); + + Assert(service.Contains("runs.ListSceneResultsAsync", StringComparison.Ordinal), "Review should continue to derive candidates from saved scene results."); + Assert(service.Contains("TryReadScene(sceneResult)", StringComparison.Ordinal), "Review should continue to read persisted parsed scene JSON."); + Assert(!service.Contains("SaveSceneResultAsync", StringComparison.Ordinal), "Character review defaults must not overwrite saved Story Intelligence scene results."); + Assert(!service.Contains("QueueAdminTextAsync", StringComparison.Ordinal), "Character review defaults must not queue new Story Intelligence work."); +} + +static void StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); + + Assert(service.Contains("parsed.CharacterAppearance", StringComparison.Ordinal), "Character review should inspect structured character appearance data."); + Assert(service.Contains("AddCharacterAppearanceAliases", StringComparison.Ordinal), "Structured appearance aliases should feed character candidates."); + Assert(service.Contains("Aliases = appearance.Aliases", StringComparison.Ordinal), "Structured appearance aliases should be reused without another AI call."); +} + static int CountCsvRows(string path) => File.ReadLines(path).Skip(1).Count(line => !string.IsNullOrWhiteSpace(line)); diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index 0dbfec8..db69743 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -73,6 +73,14 @@ public sealed class StoryIntelligenceCharacterImportService( var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID); var nameWarnings = await BuildNameIntelligenceWarningsAsync(visibleCandidates, existingIndex); + var existingOptions = existingIndex.Characters + .Select(character => new StoryIntelligenceCharacterExistingOptionViewModel + { + CharacterID = character.CharacterID, + CharacterName = character.CharacterName + }) + .OrderBy(character => character.CharacterName) + .ToList(); return new StoryIntelligenceCharacterReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, @@ -81,6 +89,7 @@ public sealed class StoryIntelligenceCharacterImportService( IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel { + DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(candidate.DisplayName, candidate.ExistingCharacterID), Key = candidate.Key, CharacterName = candidate.DisplayName, ImportName = candidate.DisplayName, @@ -90,6 +99,8 @@ public sealed class StoryIntelligenceCharacterImportService( ExampleFirstAppearance = candidate.FirstAppearanceNote, ExistingCharacterID = candidate.ExistingCharacterID, ExistingCharacterName = candidate.ExistingCharacterName, + SelectedExistingCharacterID = candidate.ExistingCharacterID, + ExistingCharacterOptions = existingOptions, NameIntelligenceWarning = nameWarnings.GetValueOrDefault(candidate.Key) }).ToList() }; @@ -210,7 +221,8 @@ public sealed class StoryIntelligenceCharacterImportService( var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase); var matchedExistingId = linkExisting - ? candidate.ExistingCharacterID + ? existingIndex.Find(choice.ExistingCharacterID)?.CharacterID + ?? candidate.ExistingCharacterID ?? existingIndex.Find(importName)?.CharacterID ?? existingIndex.Find(candidate.DisplayName)?.CharacterID : null; @@ -498,6 +510,11 @@ public sealed class StoryIntelligenceCharacterImportService( AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances); } + foreach (var appearance in parsed.CharacterAppearance ?? []) + { + AddCharacterAppearanceAliases(groups, existingIndex, importedScene, parsed, appearance, existingAppearances); + } + var povName = CleanCharacterName(parsed.PointOfView?.CharacterName); if (IsCharacterNameCandidate(povName)) { @@ -537,6 +554,31 @@ public sealed class StoryIntelligenceCharacterImportService( } } + private static void AddCharacterAppearanceAliases( + Dictionary groups, + CharacterIndex existingIndex, + Scene importedScene, + SceneIntelligenceScene parsed, + SceneIntelligenceCharacterAppearance appearance, + IReadOnlyList existingAppearances) + { + var canonicalName = CleanCharacterName(appearance.CanonicalName); + if (!IsCharacterNameCandidate(canonicalName)) + { + return; + } + + AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter + { + Name = canonicalName, + RoleInScene = appearance.RoleOrOccupation, + MentionedOnly = false, + Aliases = appearance.Aliases, + Confidence = appearance.Confidence, + Notes = appearance.Evidence + }, existingAppearances); + } + private static void AddObservedName( Dictionary groups, CharacterIndex existingIndex, @@ -570,6 +612,7 @@ public sealed class StoryIntelligenceCharacterImportService( var index = new CharacterIndex(); foreach (var character in await characters.ListCharactersAsync(projectId)) { + index.AddCharacter(character.CharacterID, character.CharacterName); index.Add(character.CharacterName, character.CharacterID, character.CharacterName); index.Add(FirstName(character.CharacterName), character.CharacterID, character.CharacterName); index.Add(character.ShortName, character.CharacterID, character.CharacterName); @@ -943,8 +986,21 @@ public sealed class StoryIntelligenceCharacterImportService( private sealed class CharacterIndex { private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary byId = new(); private readonly List rows = []; + private readonly List characters = []; public IReadOnlyList Rows => rows; + public IReadOnlyList Characters => characters; + + public void AddCharacter(int characterId, string characterName) + { + var match = new CharacterMatch(characterId, characterName); + byId.TryAdd(characterId, match); + if (!characters.Any(character => character.CharacterID == characterId)) + { + characters.Add(new CharacterIndexCharacter(characterId, characterName)); + } + } public void Add(string? name, int characterId, string characterName) { @@ -958,10 +1014,14 @@ public sealed class StoryIntelligenceCharacterImportService( public CharacterMatch? Find(string name) => byName.TryGetValue(Normalise(name), out var match) ? match : null; + + public CharacterMatch? Find(int? characterId) + => characterId.HasValue && byId.TryGetValue(characterId.Value, out var match) ? match : null; } private sealed record CharacterMatch(int CharacterID, string CharacterName); private sealed record CharacterIndexRow(string MatchName, string CharacterName); + private sealed record CharacterIndexCharacter(int CharacterID, string CharacterName); private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName) { diff --git a/PlotLine/Services/StoryIntelligenceCharacterReviewDefaults.cs b/PlotLine/Services/StoryIntelligenceCharacterReviewDefaults.cs new file mode 100644 index 0000000..835585f --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceCharacterReviewDefaults.cs @@ -0,0 +1,52 @@ +using System.Text.RegularExpressions; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public static class StoryIntelligenceCharacterReviewDefaults +{ + private static readonly HashSet GenericFamilyReferences = CreateSet( + "mum", "mom", "mam", "mother", "dad", "father", "brother", "sister", "grandmother", "grandfather", + "nan", "nanny", "grandma", "grandad", "granddad", "aunt", "auntie", "uncle", "cousin"); + + private static readonly Regex ContextualIndefiniteReference = new( + @"^(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) + { + if (proposedExistingCharacterId.HasValue) + { + return StoryIntelligenceCharacterImportActions.LinkExisting; + } + + if (IsGenericContextualReference(candidateName)) + { + return StoryIntelligenceCharacterImportActions.Ignore; + } + + return StoryIntelligenceCharacterImportActions.CreateNew; + } + + public static bool IsGenericContextualReference(string? candidateName) + { + var normalised = Normalise(candidateName); + if (string.IsNullOrWhiteSpace(normalised)) + { + return false; + } + + return GenericFamilyReferences.Contains(normalised) + || ContextualIndefiniteReference.IsMatch(normalised); + } + + private static string Normalise(string? value) + { + var cleaned = Regex.Replace(value ?? string.Empty, @"[^\p{L}\p{N}' ]+", " "); + cleaned = cleaned.Replace("’", "'", StringComparison.Ordinal); + return string.Join(' ', cleaned.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant(); + } + + private static HashSet CreateSet(params string[] values) + => values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase); +} diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index c9892e7..26ece0e 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -209,6 +209,7 @@ public sealed class StoryIntelligenceCharacterReviewCandidateViewModel public string Key { get; init; } = string.Empty; public string CharacterName { get; init; } = string.Empty; public string ImportName { get; init; } = string.Empty; + public string DefaultAction { get; init; } = StoryIntelligenceCharacterImportActions.CreateNew; public int AppearsInScenes { get; init; } public string Confidence { get; init; } = "Unknown"; public IReadOnlyList PossibleAliases { get; init; } = []; @@ -216,10 +217,19 @@ public sealed class StoryIntelligenceCharacterReviewCandidateViewModel public string? NameIntelligenceWarning { get; init; } public int? ExistingCharacterID { get; init; } public string? ExistingCharacterName { get; init; } + public int? SelectedExistingCharacterID { get; init; } + public IReadOnlyList ExistingCharacterOptions { get; init; } = []; public bool IsExistingMatch => ExistingCharacterID.HasValue; + public bool CanLinkExisting => ExistingCharacterOptions.Count > 0; public string ActionLabel => IsExistingMatch ? "Link existing" : "Create"; } +public sealed class StoryIntelligenceCharacterExistingOptionViewModel +{ + public int CharacterID { get; init; } + public string CharacterName { get; init; } = string.Empty; +} + public sealed class StoryIntelligenceCharacterImportForm { public Guid BatchID { get; set; } @@ -231,6 +241,7 @@ public sealed class StoryIntelligenceCharacterImportChoiceForm public string Key { get; set; } = string.Empty; public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew; public string? ImportName { get; set; } + public int? ExistingCharacterID { get; set; } public string? AliasTargetKey { get; set; } } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml index 31e94e7..2abc139 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml @@ -92,23 +92,41 @@
Decision
+ +
@@ -183,7 +201,9 @@ const action = actionFor(card); const namePanel = card.querySelector("[data-character-import-name-panel]"); const aliasPanel = card.querySelector("[data-character-alias-panel]"); - if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.Alias"; + const existingPanel = card.querySelector("[data-character-existing-panel]"); + if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.Alias" || action === "@StoryIntelligenceCharacterImportActions.LinkExisting"; + if (existingPanel) existingPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.LinkExisting"; if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.Alias"; updateAliasTargets(); }; @@ -212,6 +232,9 @@ @functions { private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value; + private static bool IsDefault(StoryIntelligenceCharacterReviewCandidateViewModel candidate, string action) + => string.Equals(candidate.DefaultAction, action, StringComparison.OrdinalIgnoreCase); + private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label) => new Microsoft.AspNetCore.Html.HtmlString($"
{System.Net.WebUtility.HtmlEncode(label)}Coming Soon
"); }