diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 7c834fc..1d5cf08 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -186,7 +186,10 @@ var tests = new (string Name, Action Test)[] ("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 character review uses single identity decision", StoryIntelligenceCharacterReviewUsesSingleIdentityDecision), + ("Story Intelligence auto resolves exact known identities before review", StoryIntelligenceAutoResolvesExactKnownIdentitiesBeforeReview), + ("Story Intelligence character matching keeps ambiguous known names visible", StoryIntelligenceCharacterMatchingKeepsAmbiguousKnownNamesVisible), + ("Story Intelligence descriptive references consolidate articles", StoryIntelligenceDescriptiveReferencesConsolidateArticles), ("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), @@ -528,6 +531,7 @@ static void StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces() var root = PlotLineRoot(); var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml")); var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js")); + var pageScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-progress.js")); Assert(layout.Contains("User.Identity?.IsAuthenticated == true", StringComparison.Ordinal), "Authenticated pages should always render the global progress host so a later import can appear without a full refresh."); Assert(layout.Contains("data-story-intelligence-has-active-work", StringComparison.Ordinal), "Initial render should expose whether the hidden strip currently has active work."); @@ -540,6 +544,10 @@ static void StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces() Assert(script.Contains("snapshotKey !== lastSnapshotKey", StringComparison.Ordinal), "Progress monotonicity should reset only when the active book scope changes."); Assert(script.Contains("snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "A newer but backwards snapshot for the same book must not regress the banner."); Assert(script.Contains("StoryIntelligenceGlobalProgress]"), "Development builds should expose low-noise client diagnostics."); + Assert(pageScript.Contains("const startWithRetry = () =>", StringComparison.Ordinal), "The full progress page should retry initial SignalR start failures like the working global banner."); + Assert(pageScript.Contains("connection.onclose(() =>", StringComparison.Ordinal), "The full progress page should recover after automatic reconnect is exhausted."); + Assert(pageScript.Contains("plotdirector:story-intelligence-run-progress-applied", StringComparison.Ordinal), "Incremental run events should expose a handler-level signal after the matching DOM run is updated."); + Assert(pageScript.Contains("ignored run progress for unwatched run", StringComparison.Ordinal), "Development diagnostics should show when published run IDs do not match page DOM run IDs."); } static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview() @@ -5481,22 +5489,55 @@ static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities() "Named new characters without an existing match should default to Create New."); } -static void StoryIntelligenceAliasDropdownUsesCanonicalCharacters() +static void StoryIntelligenceCharacterReviewUsesSingleIdentityDecision() { 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(model.Contains("ExistingCharacterOptions", StringComparison.Ordinal), "Character review should expose canonical existing-character targets."); 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("existingIndex.Find(choice.ExistingCharacterID)", StringComparison.Ordinal), "Import should resolve same-character 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."); + Assert(view.Contains("Same character as existing", StringComparison.Ordinal), "Review should present one clear identity decision."); + Assert(view.Contains("name=\"Characters[@i].ExistingCharacterID\"", StringComparison.Ordinal), "Same-character dropdown should post canonical CharacterID values."); + Assert(!view.Contains("Alias of another character", StringComparison.Ordinal), "Review should not expose competing alias-chain semantics."); + Assert(!view.Contains("data-character-alias-panel", StringComparison.Ordinal), "Review should not render a separate alias target panel."); +} + +static void StoryIntelligenceAutoResolvesExactKnownIdentitiesBeforeReview() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); + var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs")); + + Assert(service.Contains("ResolveAutomaticMatchesAsync", StringComparison.Ordinal), "Character import should expose deterministic auto-resolution before persisted review generation."); + Assert(service.Contains("IsAutoResolvableKnownIdentity", StringComparison.Ordinal), "Exact known identities should be recognised separately from proposed strong matches."); + Assert(service.Contains("existingIndex.Find(candidate.DisplayName)?.CharacterID == candidate.ExistingCharacterID.Value", StringComparison.Ordinal), "Only unambiguous preferred/short-name/alias lookup matches should auto-resolve."); + Assert(service.Contains("LinkCandidateAppearancesAsync(candidate, characterId", StringComparison.Ordinal), "Auto-resolution should preserve scene-character associations."); + Assert(candidateService.Contains("await characterImport.ResolveAutomaticMatchesAsync(batch)", StringComparison.Ordinal), "Persisted review preparation should resolve exact known identities before saving review rows."); +} + +static void StoryIntelligenceCharacterMatchingKeepsAmbiguousKnownNamesVisible() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); + + Assert(service.Contains("Dictionary> byName", StringComparison.Ordinal), "Known-name lookup should retain all CharacterID owners for an alias key."); + Assert(service.Contains("matches.Select(match => match.CharacterID).Distinct().Count() == 1", StringComparison.Ordinal), "Known-name lookup should only return a match when there is exactly one owner."); + Assert(service.Contains("HasAmbiguousExistingMatch", StringComparison.Ordinal), "Ambiguous known-name matches should be preserved on candidates."); + Assert(service.Contains("return StoryIntelligenceCharacterClassifications.Ambiguous", StringComparison.Ordinal), "Ambiguous known-name matches should remain visible for review."); +} + +static void StoryIntelligenceDescriptiveReferencesConsolidateArticles() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); + + Assert(service.Contains("IsMergeableArticle", StringComparison.Ordinal), "Candidate keys should normalise safe leading articles."); + Assert(service.Contains("words.RemoveAt(0);", StringComparison.Ordinal), "Barman/the barman and Creepy guy/the creepy guy should collapse to one candidate key."); + Assert(service.Contains("candidate.Aliases.Add(name)", StringComparison.Ordinal), "Consolidated references should preserve alternate observed labels as aliases/evidence."); } static void StoryIntelligenceReviewOmitsCharacterMetricsDashboard() diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index 897b859..5ef47e8 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -8,10 +8,13 @@ namespace PlotLine.Services; public interface IStoryIntelligenceCharacterImportService { + Task ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch); Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form, bool completeReview); } +public sealed record StoryIntelligenceCharacterAutoResolutionResult(int CandidatesResolved, int SceneAppearancesLinked); + public sealed class StoryIntelligenceCharacterImportService( IStoryIntelligenceResultRepository runs, ICharacterRepository characters, @@ -68,6 +71,60 @@ public sealed class StoryIntelligenceCharacterImportService( private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled); private static readonly Regex LeadingQuantity = new(@"^\s*(\d+|one|two|three|four|five|six|seven|eight|nine|ten|several|some|many|few|couple|various|other)\s+", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public async Task ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch) + { + var data = await BuildCandidateDataAsync(batch); + if (!data.HasCommittedScenes) + { + return new StoryIntelligenceCharacterAutoResolutionResult(0, 0); + } + + var lookupData = await characters.GetLookupsAsync(batch.ProjectID); + var roleTypes = lookupData.RoleTypes; + var presenceTypes = lookupData.PresenceTypes; + var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID); + var resolvedNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + var resolvedCandidates = 0; + var linkedAppearances = 0; + + foreach (var candidate in data.Candidates.Where(candidate => IsAutoResolvableKnownIdentity(candidate, existingIndex))) + { + var characterId = candidate.ExistingCharacterID!.Value; + AddResolvedName(resolvedNames, candidate.DisplayName, characterId); + foreach (var alias in candidate.Aliases) + { + AddResolvedName(resolvedNames, alias, characterId); + } + + linkedAppearances += await LinkCandidateAppearancesAsync(candidate, characterId, roleTypes, presenceTypes, candidate.ExistingCharacterName ?? candidate.DisplayName, addAliasNames: false); + resolvedCandidates++; + AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.LinkExisting, candidate.DisplayName, characterId, true); + } + + foreach (var scene in data.SceneAnalyses) + { + var povName = Clean(scene.Parsed.PointOfView?.CharacterName); + if (!string.IsNullOrWhiteSpace(povName) && resolvedNames.TryGetValue(povName, out var characterId)) + { + await scenes.UpdatePovCharacterAsync(scene.SceneID, characterId); + } + } + + if (resolvedCandidates > 0) + { + await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID); + logger.LogInformation( + "Automatically resolved Story Intelligence character candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneAppearancesLinked={SceneAppearancesLinked}", + batch.BatchID, + batch.ProjectID, + batch.BookID, + resolvedCandidates, + linkedAppearances); + } + + return new StoryIntelligenceCharacterAutoResolutionResult(resolvedCandidates, linkedAppearances); + } + public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); @@ -269,24 +326,17 @@ public sealed class StoryIntelligenceCharacterImportService( AddResolvedName(resolvedCharacters, candidate.DisplayName, characterId.Value); AddResolvedName(resolvedCharacters, importName, characterId.Value); + if (linkExisting) + { + await TryAddAliasAsync(characterId.Value, candidate.DisplayName, importName); + } foreach (var alias in candidate.Aliases) { AddResolvedName(resolvedCharacters, alias, characterId.Value); await TryAddAliasAsync(characterId.Value, alias, importName); } - foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked)) - { - await characters.SaveSceneCharacterAsync(new SceneCharacter - { - SceneID = appearance.SceneID, - CharacterID = characterId.Value, - RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene), - PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly), - AppearanceNotes = appearance.Notes - }); - linkedAppearances++; - } + linkedAppearances += await LinkCandidateAppearancesAsync(candidate, characterId.Value, roleTypes, presenceTypes, importName, addAliasNames: false); resolvedCandidateIds[candidate.Key] = characterId.Value; AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true); @@ -343,18 +393,7 @@ public sealed class StoryIntelligenceCharacterImportService( AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value); } - foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked)) - { - await characters.SaveSceneCharacterAsync(new SceneCharacter - { - SceneID = appearance.SceneID, - CharacterID = targetCharacterId.Value, - RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene), - PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly), - AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetName, appearance.Notes) - }); - linkedAppearances++; - } + linkedAppearances += await LinkCandidateAppearancesAsync(candidate, targetCharacterId.Value, roleTypes, presenceTypes, targetName, addAliasNames: false, aliasNoteTargetName: targetName); aliased++; AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId.Value, true); @@ -502,6 +541,7 @@ public sealed class StoryIntelligenceCharacterImportService( { var match = existingIndex.Find(name); candidate = new CharacterCandidate(key, name, match?.CharacterID, match?.CharacterName); + candidate.ApplyAmbiguousMatch(existingIndex.IsAmbiguous(name)); groups[key] = candidate; } else if (!string.Equals(candidate.DisplayName, name, StringComparison.OrdinalIgnoreCase)) @@ -515,6 +555,8 @@ public sealed class StoryIntelligenceCharacterImportService( if (IsCharacterNameCandidate(cleanAlias) && !string.Equals(cleanAlias, candidate.DisplayName, StringComparison.OrdinalIgnoreCase)) { candidate.Aliases.Add(cleanAlias); + candidate.ApplyProposedMatch(existingIndex.Find(cleanAlias)); + candidate.ApplyAmbiguousMatch(existingIndex.IsAmbiguous(cleanAlias)); } } @@ -667,18 +709,59 @@ public sealed class StoryIntelligenceCharacterImportService( { 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); foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) { index.Add(alias.Alias, character.CharacterID, character.CharacterName); - index.Add(FirstName(alias.Alias), character.CharacterID, character.CharacterName); } } return index; } + private static bool IsAutoResolvableKnownIdentity(CharacterCandidate candidate, CharacterIndex existingIndex) + => candidate.ExistingCharacterID.HasValue + && !candidate.HasAmbiguousExistingMatch + && !IsFamilyRoleOnly(candidate.DisplayName) + && existingIndex.Find(candidate.DisplayName)?.CharacterID == candidate.ExistingCharacterID.Value; + + private async Task LinkCandidateAppearancesAsync( + CharacterCandidate candidate, + int characterId, + IReadOnlyList roleTypes, + IReadOnlyList presenceTypes, + string primaryName, + bool addAliasNames, + string? aliasNoteTargetName = null) + { + var linkedAppearances = 0; + if (addAliasNames) + { + await TryAddAliasAsync(characterId, candidate.DisplayName, primaryName); + foreach (var alias in candidate.Aliases) + { + await TryAddAliasAsync(characterId, alias, primaryName); + } + } + + foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked)) + { + await characters.SaveSceneCharacterAsync(new SceneCharacter + { + SceneID = appearance.SceneID, + CharacterID = characterId, + RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene), + PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly), + AppearanceNotes = string.IsNullOrWhiteSpace(aliasNoteTargetName) + ? appearance.Notes + : BuildAliasAppearanceNote(candidate.DisplayName, aliasNoteTargetName, appearance.Notes) + }); + linkedAppearances++; + } + + return linkedAppearances; + } + private async Task TryAddAliasAsync(int characterId, string alias, string primaryName) { var cleanAlias = Clean(alias); @@ -853,9 +936,9 @@ public sealed class StoryIntelligenceCharacterImportService( return StoryIntelligenceCharacterClassifications.NonCharacter; } - if (candidate.ExistingCharacterID.HasValue) + if (candidate.HasAmbiguousExistingMatch) { - return StoryIntelligenceCharacterClassifications.ExistingCharacter; + return StoryIntelligenceCharacterClassifications.Ambiguous; } if (StoryIntelligenceCharacterReviewDefaults.IsGenericContextualReference(name) @@ -865,6 +948,11 @@ public sealed class StoryIntelligenceCharacterImportService( return StoryIntelligenceCharacterClassifications.GenericReference; } + if (candidate.ExistingCharacterID.HasValue) + { + return StoryIntelligenceCharacterClassifications.ExistingCharacter; + } + if (candidate.Aliases.Count > 0) { return StoryIntelligenceCharacterClassifications.NewAlias; @@ -1029,6 +1117,11 @@ public sealed class StoryIntelligenceCharacterImportService( .Where(word => !string.IsNullOrWhiteSpace(word)) .ToList(); + if (words.Count > 1 && IsMergeableArticle(words[0])) + { + words.RemoveAt(0); + } + if (words.Count > 1 && IsMergeableTitle(words[0])) { words.RemoveAt(0); @@ -1037,6 +1130,11 @@ public sealed class StoryIntelligenceCharacterImportService( return words; } + private static bool IsMergeableArticle(string value) + => value.Equals("the", StringComparison.OrdinalIgnoreCase) + || value.Equals("a", StringComparison.OrdinalIgnoreCase) + || value.Equals("an", StringComparison.OrdinalIgnoreCase); + private static List WordsForFiltering(string? name) => CleanCharacterName(name) .Replace("/", " ", StringComparison.Ordinal) @@ -1083,7 +1181,7 @@ public sealed class StoryIntelligenceCharacterImportService( private sealed class CharacterIndex { - private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> byName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary byId = new(); private readonly List rows = []; private readonly List characters = []; @@ -1102,16 +1200,37 @@ public sealed class StoryIntelligenceCharacterImportService( public void Add(string? name, int characterId, string characterName) { - var key = Normalise(name); + var key = Normalise(CleanCharacterName(name)); if (!string.IsNullOrWhiteSpace(key)) { - byName.TryAdd(key, new CharacterMatch(characterId, characterName)); + if (!byName.TryGetValue(key, out var matches)) + { + matches = []; + byName[key] = matches; + } + + if (matches.All(match => match.CharacterID != characterId)) + { + matches.Add(new CharacterMatch(characterId, characterName)); + } + rows.Add(new CharacterIndexRow(name!, characterName)); } } public CharacterMatch? Find(string name) - => byName.TryGetValue(Normalise(name), out var match) ? match : null; + { + if (!byName.TryGetValue(Normalise(CleanCharacterName(name)), out var matches)) + { + return null; + } + + return matches.Select(match => match.CharacterID).Distinct().Count() == 1 ? matches[0] : null; + } + + public bool IsAmbiguous(string name) + => byName.TryGetValue(Normalise(CleanCharacterName(name)), out var matches) + && matches.Select(match => match.CharacterID).Distinct().Count() > 1; public CharacterMatch? Find(int? characterId) => characterId.HasValue && byId.TryGetValue(characterId.Value, out var match) ? match : null; @@ -1125,14 +1244,46 @@ public sealed class StoryIntelligenceCharacterImportService( { public string Key { get; } = key; public string DisplayName { get; } = displayName; - public int? ExistingCharacterID { get; } = existingCharacterId; - public string? ExistingCharacterName { get; } = existingCharacterName; + public int? ExistingCharacterID { get; private set; } = existingCharacterId; + public string? ExistingCharacterName { get; private set; } = existingCharacterName; + public bool HasAmbiguousExistingMatch { get; private set; } public HashSet Aliases { get; } = new(StringComparer.OrdinalIgnoreCase); public List PresentAppearances { get; } = []; public List MentionedOnlyAppearances { get; } = []; public decimal? Confidence { get; set; } public string? Description { get; set; } public string? FirstAppearanceNote { get; set; } + + public void ApplyProposedMatch(CharacterMatch? match) + { + if (match is null || HasAmbiguousExistingMatch) + { + return; + } + + if (ExistingCharacterID.HasValue && ExistingCharacterID.Value != match.CharacterID) + { + HasAmbiguousExistingMatch = true; + ExistingCharacterID = null; + ExistingCharacterName = null; + return; + } + + ExistingCharacterID = match.CharacterID; + ExistingCharacterName = match.CharacterName; + } + + public void ApplyAmbiguousMatch(bool isAmbiguous) + { + if (!isAmbiguous) + { + return; + } + + HasAmbiguousExistingMatch = true; + ExistingCharacterID = null; + ExistingCharacterName = null; + } } private sealed record CharacterAppearanceImport( diff --git a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs index 8dc5859..69c250a 100644 --- a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs +++ b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs @@ -40,6 +40,7 @@ public sealed class StoryIntelligenceReviewCandidateService( public async Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch) { + var automatic = await characterImport.ResolveAutomaticMatchesAsync(batch); var review = await characterImport.BuildReviewAsync(batch); await PersistAsync( batch, @@ -50,6 +51,16 @@ public sealed class StoryIntelligenceReviewCandidateService( candidate => candidate.Classification, candidate => candidate.ExistingCharacterID, candidate => candidate.PossibleAliases.Take(5)); + if (automatic.CandidatesResolved > 0) + { + logger.LogInformation( + "Persisted Story Intelligence character review omitted automatically resolved identities. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneAppearancesLinked={SceneAppearancesLinked}", + batch.BatchID, + batch.ProjectID, + batch.BookID, + automatic.CandidatesResolved, + automatic.SceneAppearancesLinked); + } } public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch) diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml index 6c40925..9a0c67d 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml @@ -60,7 +60,7 @@ {
- + @@ -102,7 +102,7 @@ {
Possible existing character -

@candidate.CharacterName may already be @candidate.ExistingCharacterName. Choose whether to link them or create a separate character.

+

@candidate.CharacterName may already be @candidate.ExistingCharacterName. Choose whether this is the same character or a separate character.

} @@ -114,11 +114,7 @@ -
} @@ -202,44 +186,12 @@ } }; const actionFor = (card) => card.querySelector("[data-character-action]:checked")?.value || "@StoryIntelligenceCharacterImportActions.CreateNew"; - const isCanonicalTarget = (card) => { - const action = actionFor(card); - return action !== "@StoryIntelligenceCharacterImportActions.Ignore" && action !== "@StoryIntelligenceCharacterImportActions.Alias"; - }; - const updateAliasTargets = () => { - const allCards = cards(); - for (const card of allCards) { - const body = card.querySelector("[data-character-key]"); - const ownKey = body?.getAttribute("data-character-key") || ""; - const select = card.querySelector("[data-character-alias-target]"); - if (!select) continue; - - 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); - } - if (select.selectedOptions[0]?.disabled) { - select.value = ""; - } else { - select.value = previous; - } - } - }; const updateCard = (card) => { const action = actionFor(card); const namePanel = card.querySelector("[data-character-import-name-panel]"); - const aliasPanel = card.querySelector("[data-character-alias-panel]"); const existingPanel = card.querySelector("[data-character-existing-panel]"); - if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.Alias" || action === "@StoryIntelligenceCharacterImportActions.LinkExisting"; + if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.LinkExisting"; if (existingPanel) existingPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.LinkExisting"; - if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.Alias"; - updateAliasTargets(); }; document.querySelectorAll("[data-character-bulk]").forEach(button => { button.addEventListener("click", () => { diff --git a/PlotLine/wwwroot/js/story-intelligence-progress.js b/PlotLine/wwwroot/js/story-intelligence-progress.js index 169fe9f..07c3ac9 100644 --- a/PlotLine/wwwroot/js/story-intelligence-progress.js +++ b/PlotLine/wwwroot/js/story-intelligence-progress.js @@ -14,6 +14,15 @@ .flatMap((root) => (root.dataset.storyIntelligenceRunIds || "").split(",")) .map((value) => value.trim()) .filter(Boolean))]; + const diagnosticsEnabled = + location.hostname === "localhost" + || location.hostname === "127.0.0.1" + || location.hostname.startsWith("dev."); + const log = (message, data) => { + if (diagnosticsEnabled && window.console?.debug) { + window.console.debug(`[StoryIntelligenceProgress] ${message}`, data || ""); + } + }; const numberText = (value) => { const number = Number.parseInt(value ?? "0", 10); @@ -399,14 +408,17 @@ const applyRunProgress = (state) => { const runId = field(state, "runID", "RunID") || field(state, "runId", "RunId"); if (!runId) { + log("ignored run progress without run id", state); return; } + let matched = false; onboardingRoots.forEach((root) => { const chapter = root.querySelector(`[data-story-run-id="${runId}"]`); if (!chapter) { return; } + matched = true; const status = field(state, "status", "Status") || "Pending"; const stage = field(state, "currentStage", "CurrentStage") || status; @@ -527,6 +539,19 @@ recalculateOnboardingTotals(root); }); + + if (!matched) { + log("ignored run progress for unwatched run", { + runId, + watchedRunIds: runIds, + payload: state + }); + } else { + log("applied run progress", { runId, status: field(state, "status", "Status"), completedScenes: field(state, "completedScenes", "CompletedScenes") }); + document.dispatchEvent(new CustomEvent("plotdirector:story-intelligence-run-progress-applied", { + detail: { runId: String(runId), state } + })); + } }; const connection = new signalR.HubConnectionBuilder() @@ -537,23 +562,40 @@ connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress); connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress); connection.onreconnected(() => { + log("SignalR reconnected"); legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyLegacyProgress).catch(() => {})); runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)).then(applyRunProgress).catch(() => {})); }); + connection.onclose(() => { + log("SignalR closed; retrying start"); + startWithRetry(); + }); - connection.start() - .then(() => Promise.all([ + let startRetryTimer = null; + const startWithRetry = () => { + window.clearTimeout(startRetryTimer); + connection.start() + .then(() => { + log("SignalR connected", { runIds, legacyJobIds }); + return Promise.all([ ...legacyJobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)), ...runIds.map((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10))) - ])) - .then((states) => { - states.filter(Boolean).forEach((state) => { - if (field(state, "runID", "RunID") || field(state, "runId", "RunId")) { - applyRunProgress(state); - } else { - applyLegacyProgress(state); - } + ]); + }) + .then((states) => { + states.filter(Boolean).forEach((state) => { + if (field(state, "runID", "RunID") || field(state, "runId", "RunId")) { + applyRunProgress(state); + } else { + applyLegacyProgress(state); + } + }); + }) + .catch(() => { + log("SignalR start or watch failed; retrying"); + startRetryTimer = window.setTimeout(startWithRetry, 2000); }); - }) - .catch(() => {}); + }; + + startWithRetry(); })();