Fix Story Intelligence progress and character identity review

This commit is contained in:
Nick Beckley 2026-08-31 19:08:00 +00:00
parent 45051a8556
commit 4579476869
5 changed files with 305 additions and 108 deletions

View File

@ -186,7 +186,10 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence character defaults link proposed existing matches", StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches), ("Story Intelligence character defaults link proposed existing matches", StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches),
("Story Intelligence character defaults ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences), ("Story Intelligence character defaults ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences),
("Story Intelligence character defaults keep descriptive identities", StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities), ("Story Intelligence character defaults keep descriptive identities", StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities),
("Story Intelligence alias dropdown uses canonical characters", StoryIntelligenceAliasDropdownUsesCanonicalCharacters), ("Story Intelligence 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 review omits character metrics dashboard", StoryIntelligenceReviewOmitsCharacterMetricsDashboard),
("Story Intelligence resumed chapters use canonical labels", StoryIntelligenceResumedChaptersUseCanonicalLabels), ("Story Intelligence resumed chapters use canonical labels", StoryIntelligenceResumedChaptersUseCanonicalLabels),
("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch), ("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch),
@ -528,6 +531,7 @@ static void StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces()
var root = PlotLineRoot(); var root = PlotLineRoot();
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml")); 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 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("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."); 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("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("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(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() static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview()
@ -5481,22 +5489,55 @@ static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities()
"Named new characters without an existing match should default to Create New."); "Named new characters without an existing match should default to Create New.");
} }
static void StoryIntelligenceAliasDropdownUsesCanonicalCharacters() static void StoryIntelligenceCharacterReviewUsesSingleIdentityDecision()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs")); var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml")); 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("ExistingCharacterOptions", StringComparison.Ordinal), "Character review should expose canonical existing-character 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(".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(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("Same character as existing", StringComparison.Ordinal), "Review should present one clear identity decision.");
Assert(view.Contains("Model.CharacterReview.AliasTargetOptions", StringComparison.Ordinal), "Alias dropdown should render canonical targets, not unresolved candidates."); Assert(view.Contains("name=\"Characters[@i].ExistingCharacterID\"", StringComparison.Ordinal), "Same-character dropdown should post canonical CharacterID values.");
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("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<string, List<CharacterMatch>> 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() static void StoryIntelligenceReviewOmitsCharacterMetricsDashboard()

View File

@ -8,10 +8,13 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceCharacterImportService public interface IStoryIntelligenceCharacterImportService
{ {
Task<StoryIntelligenceCharacterAutoResolutionResult> ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form, bool completeReview); Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form, bool completeReview);
} }
public sealed record StoryIntelligenceCharacterAutoResolutionResult(int CandidatesResolved, int SceneAppearancesLinked);
public sealed class StoryIntelligenceCharacterImportService( public sealed class StoryIntelligenceCharacterImportService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
ICharacterRepository characters, ICharacterRepository characters,
@ -68,6 +71,60 @@ public sealed class StoryIntelligenceCharacterImportService(
private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled); 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); 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<StoryIntelligenceCharacterAutoResolutionResult> 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<string, int>(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<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) public async Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{ {
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID); 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, candidate.DisplayName, characterId.Value);
AddResolvedName(resolvedCharacters, importName, characterId.Value); AddResolvedName(resolvedCharacters, importName, characterId.Value);
if (linkExisting)
{
await TryAddAliasAsync(characterId.Value, candidate.DisplayName, importName);
}
foreach (var alias in candidate.Aliases) foreach (var alias in candidate.Aliases)
{ {
AddResolvedName(resolvedCharacters, alias, characterId.Value); AddResolvedName(resolvedCharacters, alias, characterId.Value);
await TryAddAliasAsync(characterId.Value, alias, importName); await TryAddAliasAsync(characterId.Value, alias, importName);
} }
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked)) linkedAppearances += await LinkCandidateAppearancesAsync(candidate, characterId.Value, roleTypes, presenceTypes, importName, addAliasNames: false);
{
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++;
}
resolvedCandidateIds[candidate.Key] = characterId.Value; resolvedCandidateIds[candidate.Key] = characterId.Value;
AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true); AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true);
@ -343,18 +393,7 @@ public sealed class StoryIntelligenceCharacterImportService(
AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value); AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value);
} }
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked)) linkedAppearances += await LinkCandidateAppearancesAsync(candidate, targetCharacterId.Value, roleTypes, presenceTypes, targetName, addAliasNames: false, aliasNoteTargetName: targetName);
{
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++;
}
aliased++; aliased++;
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId.Value, true); AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId.Value, true);
@ -502,6 +541,7 @@ public sealed class StoryIntelligenceCharacterImportService(
{ {
var match = existingIndex.Find(name); var match = existingIndex.Find(name);
candidate = new CharacterCandidate(key, name, match?.CharacterID, match?.CharacterName); candidate = new CharacterCandidate(key, name, match?.CharacterID, match?.CharacterName);
candidate.ApplyAmbiguousMatch(existingIndex.IsAmbiguous(name));
groups[key] = candidate; groups[key] = candidate;
} }
else if (!string.Equals(candidate.DisplayName, name, StringComparison.OrdinalIgnoreCase)) 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)) if (IsCharacterNameCandidate(cleanAlias) && !string.Equals(cleanAlias, candidate.DisplayName, StringComparison.OrdinalIgnoreCase))
{ {
candidate.Aliases.Add(cleanAlias); 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.AddCharacter(character.CharacterID, character.CharacterName);
index.Add(character.CharacterName, 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); index.Add(character.ShortName, character.CharacterID, character.CharacterName);
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
{ {
index.Add(alias.Alias, character.CharacterID, character.CharacterName); index.Add(alias.Alias, character.CharacterID, character.CharacterName);
index.Add(FirstName(alias.Alias), character.CharacterID, character.CharacterName);
} }
} }
return index; 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<int> LinkCandidateAppearancesAsync(
CharacterCandidate candidate,
int characterId,
IReadOnlyList<CharacterRoleInSceneType> roleTypes,
IReadOnlyList<PresenceType> 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) private async Task TryAddAliasAsync(int characterId, string alias, string primaryName)
{ {
var cleanAlias = Clean(alias); var cleanAlias = Clean(alias);
@ -853,9 +936,9 @@ public sealed class StoryIntelligenceCharacterImportService(
return StoryIntelligenceCharacterClassifications.NonCharacter; return StoryIntelligenceCharacterClassifications.NonCharacter;
} }
if (candidate.ExistingCharacterID.HasValue) if (candidate.HasAmbiguousExistingMatch)
{ {
return StoryIntelligenceCharacterClassifications.ExistingCharacter; return StoryIntelligenceCharacterClassifications.Ambiguous;
} }
if (StoryIntelligenceCharacterReviewDefaults.IsGenericContextualReference(name) if (StoryIntelligenceCharacterReviewDefaults.IsGenericContextualReference(name)
@ -865,6 +948,11 @@ public sealed class StoryIntelligenceCharacterImportService(
return StoryIntelligenceCharacterClassifications.GenericReference; return StoryIntelligenceCharacterClassifications.GenericReference;
} }
if (candidate.ExistingCharacterID.HasValue)
{
return StoryIntelligenceCharacterClassifications.ExistingCharacter;
}
if (candidate.Aliases.Count > 0) if (candidate.Aliases.Count > 0)
{ {
return StoryIntelligenceCharacterClassifications.NewAlias; return StoryIntelligenceCharacterClassifications.NewAlias;
@ -1029,6 +1117,11 @@ public sealed class StoryIntelligenceCharacterImportService(
.Where(word => !string.IsNullOrWhiteSpace(word)) .Where(word => !string.IsNullOrWhiteSpace(word))
.ToList(); .ToList();
if (words.Count > 1 && IsMergeableArticle(words[0]))
{
words.RemoveAt(0);
}
if (words.Count > 1 && IsMergeableTitle(words[0])) if (words.Count > 1 && IsMergeableTitle(words[0]))
{ {
words.RemoveAt(0); words.RemoveAt(0);
@ -1037,6 +1130,11 @@ public sealed class StoryIntelligenceCharacterImportService(
return words; 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<string> WordsForFiltering(string? name) private static List<string> WordsForFiltering(string? name)
=> CleanCharacterName(name) => CleanCharacterName(name)
.Replace("/", " ", StringComparison.Ordinal) .Replace("/", " ", StringComparison.Ordinal)
@ -1083,7 +1181,7 @@ public sealed class StoryIntelligenceCharacterImportService(
private sealed class CharacterIndex private sealed class CharacterIndex
{ {
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, List<CharacterMatch>> byName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<int, CharacterMatch> byId = new(); private readonly Dictionary<int, CharacterMatch> byId = new();
private readonly List<CharacterIndexRow> rows = []; private readonly List<CharacterIndexRow> rows = [];
private readonly List<CharacterIndexCharacter> characters = []; private readonly List<CharacterIndexCharacter> characters = [];
@ -1102,16 +1200,37 @@ public sealed class StoryIntelligenceCharacterImportService(
public void Add(string? name, int characterId, string characterName) public void Add(string? name, int characterId, string characterName)
{ {
var key = Normalise(name); var key = Normalise(CleanCharacterName(name));
if (!string.IsNullOrWhiteSpace(key)) 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)); rows.Add(new CharacterIndexRow(name!, characterName));
} }
} }
public CharacterMatch? Find(string name) 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) public CharacterMatch? Find(int? characterId)
=> characterId.HasValue && byId.TryGetValue(characterId.Value, out var match) ? match : null; => 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 Key { get; } = key;
public string DisplayName { get; } = displayName; public string DisplayName { get; } = displayName;
public int? ExistingCharacterID { get; } = existingCharacterId; public int? ExistingCharacterID { get; private set; } = existingCharacterId;
public string? ExistingCharacterName { get; } = existingCharacterName; public string? ExistingCharacterName { get; private set; } = existingCharacterName;
public bool HasAmbiguousExistingMatch { get; private set; }
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase); public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<CharacterAppearanceImport> PresentAppearances { get; } = []; public List<CharacterAppearanceImport> PresentAppearances { get; } = [];
public List<CharacterAppearanceImport> MentionedOnlyAppearances { get; } = []; public List<CharacterAppearanceImport> MentionedOnlyAppearances { get; } = [];
public decimal? Confidence { get; set; } public decimal? Confidence { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public string? FirstAppearanceNote { 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( private sealed record CharacterAppearanceImport(

View File

@ -40,6 +40,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
public async Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch) public async Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{ {
var automatic = await characterImport.ResolveAutomaticMatchesAsync(batch);
var review = await characterImport.BuildReviewAsync(batch); var review = await characterImport.BuildReviewAsync(batch);
await PersistAsync( await PersistAsync(
batch, batch,
@ -50,6 +51,16 @@ public sealed class StoryIntelligenceReviewCandidateService(
candidate => candidate.Classification, candidate => candidate.Classification,
candidate => candidate.ExistingCharacterID, candidate => candidate.ExistingCharacterID,
candidate => candidate.PossibleAliases.Take(5)); 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) public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch)

View File

@ -60,7 +60,7 @@
{ {
<div class="story-review-card-actions"> <div class="story-review-card-actions">
<button class="btn btn-outline-primary btn-sm" type="button" data-character-bulk="create-all">Create all</button> <button class="btn btn-outline-primary btn-sm" type="button" data-character-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="link-existing">Link existing matches</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="link-existing">Use same-character matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="ignore-all">Ignore all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="ignore-all">Ignore all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="expand">Expand all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-character-bulk="collapse">Collapse all</button>
@ -102,7 +102,7 @@
{ {
<div class="story-review-note"> <div class="story-review-note">
<strong>Possible existing character</strong> <strong>Possible existing character</strong>
<p>@candidate.CharacterName may already be @candidate.ExistingCharacterName. Choose whether to link them or create a separate character.</p> <p>@candidate.CharacterName may already be @candidate.ExistingCharacterName. Choose whether this is the same character or a separate character.</p>
</div> </div>
} }
@ -114,11 +114,7 @@
</label> </label>
<label> <label>
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.LinkExisting" data-character-action @(candidate.CanLinkExisting ? string.Empty : "disabled") @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.LinkExisting) ? "checked" : string.Empty) /> <input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.LinkExisting" data-character-action @(candidate.CanLinkExisting ? string.Empty : "disabled") @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.LinkExisting) ? "checked" : string.Empty) />
Link existing character Same character as existing
</label>
<label>
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Alias" @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.Alias) ? "checked" : string.Empty) data-character-action />
Alias of another character
</label> </label>
<label> <label>
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.Ignore) ? "checked" : string.Empty) data-character-action /> <input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.Ignore) ? "checked" : string.Empty) data-character-action />
@ -127,7 +123,7 @@
</fieldset> </fieldset>
<div data-character-existing-panel hidden> <div data-character-existing-panel hidden>
<label class="form-label" for="character-existing-@i">Existing character</label> <label class="form-label" for="character-existing-@i">Same character as</label>
<select id="character-existing-@i" class="form-select" name="Characters[@i].ExistingCharacterID" data-character-existing-target> <select id="character-existing-@i" class="form-select" name="Characters[@i].ExistingCharacterID" data-character-existing-target>
<option value="">Choose character...</option> <option value="">Choose character...</option>
@foreach (var existing in candidate.ExistingCharacterOptions) @foreach (var existing in candidate.ExistingCharacterOptions)
@ -148,18 +144,6 @@
<label class="form-label" for="character-import-name-@i">Import name</label> <label class="form-label" for="character-import-name-@i">Import name</label>
<input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" /> <input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" />
</div> </div>
<div data-character-alias-panel hidden>
<label class="form-label" for="character-alias-target-@i">Alias target</label>
<p class="form-text">Treats both identities as the same physical character and keeps this manuscript name as an alias/reference.</p>
<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.AliasTargetOptions)
{
<option value="@target.CharacterID" data-canonical="true">@target.CharacterName</option>
}
</select>
</div>
</div> </div>
</details> </details>
} }
@ -202,44 +186,12 @@
} }
}; };
const actionFor = (card) => card.querySelector("[data-character-action]:checked")?.value || "@StoryIntelligenceCharacterImportActions.CreateNew"; 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 updateCard = (card) => {
const action = actionFor(card); const action = actionFor(card);
const namePanel = card.querySelector("[data-character-import-name-panel]"); 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]"); 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 (existingPanel) existingPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.LinkExisting";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.Alias";
updateAliasTargets();
}; };
document.querySelectorAll("[data-character-bulk]").forEach(button => { document.querySelectorAll("[data-character-bulk]").forEach(button => {
button.addEventListener("click", () => { button.addEventListener("click", () => {

View File

@ -14,6 +14,15 @@
.flatMap((root) => (root.dataset.storyIntelligenceRunIds || "").split(",")) .flatMap((root) => (root.dataset.storyIntelligenceRunIds || "").split(","))
.map((value) => value.trim()) .map((value) => value.trim())
.filter(Boolean))]; .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 numberText = (value) => {
const number = Number.parseInt(value ?? "0", 10); const number = Number.parseInt(value ?? "0", 10);
@ -399,14 +408,17 @@
const applyRunProgress = (state) => { const applyRunProgress = (state) => {
const runId = field(state, "runID", "RunID") || field(state, "runId", "RunId"); const runId = field(state, "runID", "RunID") || field(state, "runId", "RunId");
if (!runId) { if (!runId) {
log("ignored run progress without run id", state);
return; return;
} }
let matched = false;
onboardingRoots.forEach((root) => { onboardingRoots.forEach((root) => {
const chapter = root.querySelector(`[data-story-run-id="${runId}"]`); const chapter = root.querySelector(`[data-story-run-id="${runId}"]`);
if (!chapter) { if (!chapter) {
return; return;
} }
matched = true;
const status = field(state, "status", "Status") || "Pending"; const status = field(state, "status", "Status") || "Pending";
const stage = field(state, "currentStage", "CurrentStage") || status; const stage = field(state, "currentStage", "CurrentStage") || status;
@ -527,6 +539,19 @@
recalculateOnboardingTotals(root); 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() const connection = new signalR.HubConnectionBuilder()
@ -537,23 +562,40 @@
connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress); connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress);
connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress); connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress);
connection.onreconnected(() => { connection.onreconnected(() => {
log("SignalR reconnected");
legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyLegacyProgress).catch(() => {})); legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyLegacyProgress).catch(() => {}));
runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)).then(applyRunProgress).catch(() => {})); runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)).then(applyRunProgress).catch(() => {}));
}); });
connection.onclose(() => {
log("SignalR closed; retrying start");
startWithRetry();
});
connection.start() let startRetryTimer = null;
.then(() => Promise.all([ const startWithRetry = () => {
window.clearTimeout(startRetryTimer);
connection.start()
.then(() => {
log("SignalR connected", { runIds, legacyJobIds });
return Promise.all([
...legacyJobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)), ...legacyJobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)),
...runIds.map((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10))) ...runIds.map((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)))
])) ]);
.then((states) => { })
states.filter(Boolean).forEach((state) => { .then((states) => {
if (field(state, "runID", "RunID") || field(state, "runId", "RunId")) { states.filter(Boolean).forEach((state) => {
applyRunProgress(state); if (field(state, "runID", "RunID") || field(state, "runId", "RunId")) {
} else { applyRunProgress(state);
applyLegacyProgress(state); } else {
} applyLegacyProgress(state);
}
});
})
.catch(() => {
log("SignalR start or watch failed; retrying");
startRetryTimer = window.setTimeout(startWithRetry, 2000);
}); });
}) };
.catch(() => {});
startWithRetry();
})(); })();