Fix Story Intelligence progress and character identity review
This commit is contained in:
parent
45051a8556
commit
4579476869
@ -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()
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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,15 +562,26 @@
|
|||||||
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
let startRetryTimer = null;
|
||||||
|
const startWithRetry = () => {
|
||||||
|
window.clearTimeout(startRetryTimer);
|
||||||
connection.start()
|
connection.start()
|
||||||
.then(() => Promise.all([
|
.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) => {
|
.then((states) => {
|
||||||
states.filter(Boolean).forEach((state) => {
|
states.filter(Boolean).forEach((state) => {
|
||||||
if (field(state, "runID", "RunID") || field(state, "runId", "RunId")) {
|
if (field(state, "runID", "RunID") || field(state, "runId", "RunId")) {
|
||||||
@ -555,5 +591,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
log("SignalR start or watch failed; retrying");
|
||||||
|
startRetryTimer = window.setTimeout(startWithRetry, 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
startWithRetry();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user