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 ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences),
|
||||
("Story Intelligence character defaults keep descriptive identities", StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities),
|
||||
("Story Intelligence alias dropdown uses canonical characters", StoryIntelligenceAliasDropdownUsesCanonicalCharacters),
|
||||
("Story Intelligence character review uses single identity decision", StoryIntelligenceCharacterReviewUsesSingleIdentityDecision),
|
||||
("Story Intelligence auto resolves exact known identities before review", StoryIntelligenceAutoResolvesExactKnownIdentitiesBeforeReview),
|
||||
("Story Intelligence character matching keeps ambiguous known names visible", StoryIntelligenceCharacterMatchingKeepsAmbiguousKnownNamesVisible),
|
||||
("Story Intelligence descriptive references consolidate articles", StoryIntelligenceDescriptiveReferencesConsolidateArticles),
|
||||
("Story Intelligence review omits character metrics dashboard", StoryIntelligenceReviewOmitsCharacterMetricsDashboard),
|
||||
("Story Intelligence resumed chapters use canonical labels", StoryIntelligenceResumedChaptersUseCanonicalLabels),
|
||||
("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch),
|
||||
@ -528,6 +531,7 @@ static void StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces()
|
||||
var root = PlotLineRoot();
|
||||
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
|
||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
|
||||
var pageScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-progress.js"));
|
||||
|
||||
Assert(layout.Contains("User.Identity?.IsAuthenticated == true", StringComparison.Ordinal), "Authenticated pages should always render the global progress host so a later import can appear without a full refresh.");
|
||||
Assert(layout.Contains("data-story-intelligence-has-active-work", StringComparison.Ordinal), "Initial render should expose whether the hidden strip currently has active work.");
|
||||
@ -540,6 +544,10 @@ static void StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces()
|
||||
Assert(script.Contains("snapshotKey !== lastSnapshotKey", StringComparison.Ordinal), "Progress monotonicity should reset only when the active book scope changes.");
|
||||
Assert(script.Contains("snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "A newer but backwards snapshot for the same book must not regress the banner.");
|
||||
Assert(script.Contains("StoryIntelligenceGlobalProgress]"), "Development builds should expose low-noise client diagnostics.");
|
||||
Assert(pageScript.Contains("const startWithRetry = () =>", StringComparison.Ordinal), "The full progress page should retry initial SignalR start failures like the working global banner.");
|
||||
Assert(pageScript.Contains("connection.onclose(() =>", StringComparison.Ordinal), "The full progress page should recover after automatic reconnect is exhausted.");
|
||||
Assert(pageScript.Contains("plotdirector:story-intelligence-run-progress-applied", StringComparison.Ordinal), "Incremental run events should expose a handler-level signal after the matching DOM run is updated.");
|
||||
Assert(pageScript.Contains("ignored run progress for unwatched run", StringComparison.Ordinal), "Development diagnostics should show when published run IDs do not match page DOM run IDs.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview()
|
||||
@ -5481,22 +5489,55 @@ static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities()
|
||||
"Named new characters without an existing match should default to Create New.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceAliasDropdownUsesCanonicalCharacters()
|
||||
static void StoryIntelligenceCharacterReviewUsesSingleIdentityDecision()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
|
||||
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml"));
|
||||
|
||||
Assert(model.Contains("AliasTargetOptions", StringComparison.Ordinal), "Character review should expose canonical alias targets.");
|
||||
Assert(model.Contains("public int? AliasTargetCharacterID", StringComparison.Ordinal), "Alias submissions should post canonical CharacterID targets.");
|
||||
Assert(service.Contains("AliasTargetOptions = existingOptions", StringComparison.Ordinal), "Alias target options should come from canonical existing characters.");
|
||||
Assert(model.Contains("ExistingCharacterOptions", StringComparison.Ordinal), "Character review should expose canonical existing-character targets.");
|
||||
Assert(service.Contains(".OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal), "Canonical alias targets should be alphabetically ordered.");
|
||||
Assert(service.Contains("existingIndex.Find(choice.AliasTargetCharacterID)", StringComparison.Ordinal), "Import should resolve alias targets by canonical CharacterID.");
|
||||
Assert(service.Contains("existingIndex.Find(choice.ExistingCharacterID)", StringComparison.Ordinal), "Import should resolve same-character targets by canonical CharacterID.");
|
||||
Assert(service.Contains("coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID)", StringComparison.Ordinal), "Character import should rerun deterministic scene-character mapping after accepted aliases or new characters.");
|
||||
Assert(view.Contains("name=\"Characters[@i].AliasTargetCharacterID\"", StringComparison.Ordinal), "Alias dropdown should post canonical CharacterID values.");
|
||||
Assert(view.Contains("Model.CharacterReview.AliasTargetOptions", StringComparison.Ordinal), "Alias dropdown should render canonical targets, not unresolved candidates.");
|
||||
Assert(!view.Contains("Model.CharacterReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key", StringComparison.Ordinal), "Alias dropdown must not be populated from unresolved candidates.");
|
||||
Assert(view.Contains("Same character as existing", StringComparison.Ordinal), "Review should present one clear identity decision.");
|
||||
Assert(view.Contains("name=\"Characters[@i].ExistingCharacterID\"", StringComparison.Ordinal), "Same-character dropdown should post canonical CharacterID values.");
|
||||
Assert(!view.Contains("Alias of another character", StringComparison.Ordinal), "Review should not expose competing alias-chain semantics.");
|
||||
Assert(!view.Contains("data-character-alias-panel", StringComparison.Ordinal), "Review should not render a separate alias target panel.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceAutoResolvesExactKnownIdentitiesBeforeReview()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
|
||||
var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
|
||||
|
||||
Assert(service.Contains("ResolveAutomaticMatchesAsync", StringComparison.Ordinal), "Character import should expose deterministic auto-resolution before persisted review generation.");
|
||||
Assert(service.Contains("IsAutoResolvableKnownIdentity", StringComparison.Ordinal), "Exact known identities should be recognised separately from proposed strong matches.");
|
||||
Assert(service.Contains("existingIndex.Find(candidate.DisplayName)?.CharacterID == candidate.ExistingCharacterID.Value", StringComparison.Ordinal), "Only unambiguous preferred/short-name/alias lookup matches should auto-resolve.");
|
||||
Assert(service.Contains("LinkCandidateAppearancesAsync(candidate, characterId", StringComparison.Ordinal), "Auto-resolution should preserve scene-character associations.");
|
||||
Assert(candidateService.Contains("await characterImport.ResolveAutomaticMatchesAsync(batch)", StringComparison.Ordinal), "Persisted review preparation should resolve exact known identities before saving review rows.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceCharacterMatchingKeepsAmbiguousKnownNamesVisible()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
|
||||
|
||||
Assert(service.Contains("Dictionary<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()
|
||||
|
||||
@ -8,10 +8,13 @@ namespace PlotLine.Services;
|
||||
|
||||
public interface IStoryIntelligenceCharacterImportService
|
||||
{
|
||||
Task<StoryIntelligenceCharacterAutoResolutionResult> ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task<StoryIntelligenceCharacterReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form, bool completeReview);
|
||||
}
|
||||
|
||||
public sealed record StoryIntelligenceCharacterAutoResolutionResult(int CandidatesResolved, int SceneAppearancesLinked);
|
||||
|
||||
public sealed class StoryIntelligenceCharacterImportService(
|
||||
IStoryIntelligenceResultRepository runs,
|
||||
ICharacterRepository characters,
|
||||
@ -68,6 +71,60 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex LeadingQuantity = new(@"^\s*(\d+|one|two|three|four|five|six|seven|eight|nine|ten|several|some|many|few|couple|various|other)\s+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public async Task<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)
|
||||
{
|
||||
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
||||
@ -269,24 +326,17 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
|
||||
AddResolvedName(resolvedCharacters, candidate.DisplayName, characterId.Value);
|
||||
AddResolvedName(resolvedCharacters, importName, characterId.Value);
|
||||
if (linkExisting)
|
||||
{
|
||||
await TryAddAliasAsync(characterId.Value, candidate.DisplayName, importName);
|
||||
}
|
||||
foreach (var alias in candidate.Aliases)
|
||||
{
|
||||
AddResolvedName(resolvedCharacters, alias, characterId.Value);
|
||||
await TryAddAliasAsync(characterId.Value, alias, importName);
|
||||
}
|
||||
|
||||
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
|
||||
{
|
||||
await characters.SaveSceneCharacterAsync(new SceneCharacter
|
||||
{
|
||||
SceneID = appearance.SceneID,
|
||||
CharacterID = characterId.Value,
|
||||
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
||||
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
||||
AppearanceNotes = appearance.Notes
|
||||
});
|
||||
linkedAppearances++;
|
||||
}
|
||||
linkedAppearances += await LinkCandidateAppearancesAsync(candidate, characterId.Value, roleTypes, presenceTypes, importName, addAliasNames: false);
|
||||
|
||||
resolvedCandidateIds[candidate.Key] = characterId.Value;
|
||||
AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true);
|
||||
@ -343,18 +393,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value);
|
||||
}
|
||||
|
||||
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
|
||||
{
|
||||
await characters.SaveSceneCharacterAsync(new SceneCharacter
|
||||
{
|
||||
SceneID = appearance.SceneID,
|
||||
CharacterID = targetCharacterId.Value,
|
||||
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
||||
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
||||
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetName, appearance.Notes)
|
||||
});
|
||||
linkedAppearances++;
|
||||
}
|
||||
linkedAppearances += await LinkCandidateAppearancesAsync(candidate, targetCharacterId.Value, roleTypes, presenceTypes, targetName, addAliasNames: false, aliasNoteTargetName: targetName);
|
||||
|
||||
aliased++;
|
||||
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId.Value, true);
|
||||
@ -502,6 +541,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
{
|
||||
var match = existingIndex.Find(name);
|
||||
candidate = new CharacterCandidate(key, name, match?.CharacterID, match?.CharacterName);
|
||||
candidate.ApplyAmbiguousMatch(existingIndex.IsAmbiguous(name));
|
||||
groups[key] = candidate;
|
||||
}
|
||||
else if (!string.Equals(candidate.DisplayName, name, StringComparison.OrdinalIgnoreCase))
|
||||
@ -515,6 +555,8 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
if (IsCharacterNameCandidate(cleanAlias) && !string.Equals(cleanAlias, candidate.DisplayName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
candidate.Aliases.Add(cleanAlias);
|
||||
candidate.ApplyProposedMatch(existingIndex.Find(cleanAlias));
|
||||
candidate.ApplyAmbiguousMatch(existingIndex.IsAmbiguous(cleanAlias));
|
||||
}
|
||||
}
|
||||
|
||||
@ -667,18 +709,59 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
{
|
||||
index.AddCharacter(character.CharacterID, character.CharacterName);
|
||||
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
|
||||
index.Add(FirstName(character.CharacterName), character.CharacterID, character.CharacterName);
|
||||
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
|
||||
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
|
||||
{
|
||||
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
|
||||
index.Add(FirstName(alias.Alias), character.CharacterID, character.CharacterName);
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static bool IsAutoResolvableKnownIdentity(CharacterCandidate candidate, CharacterIndex existingIndex)
|
||||
=> candidate.ExistingCharacterID.HasValue
|
||||
&& !candidate.HasAmbiguousExistingMatch
|
||||
&& !IsFamilyRoleOnly(candidate.DisplayName)
|
||||
&& existingIndex.Find(candidate.DisplayName)?.CharacterID == candidate.ExistingCharacterID.Value;
|
||||
|
||||
private async Task<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)
|
||||
{
|
||||
var cleanAlias = Clean(alias);
|
||||
@ -853,9 +936,9 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
return StoryIntelligenceCharacterClassifications.NonCharacter;
|
||||
}
|
||||
|
||||
if (candidate.ExistingCharacterID.HasValue)
|
||||
if (candidate.HasAmbiguousExistingMatch)
|
||||
{
|
||||
return StoryIntelligenceCharacterClassifications.ExistingCharacter;
|
||||
return StoryIntelligenceCharacterClassifications.Ambiguous;
|
||||
}
|
||||
|
||||
if (StoryIntelligenceCharacterReviewDefaults.IsGenericContextualReference(name)
|
||||
@ -865,6 +948,11 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
return StoryIntelligenceCharacterClassifications.GenericReference;
|
||||
}
|
||||
|
||||
if (candidate.ExistingCharacterID.HasValue)
|
||||
{
|
||||
return StoryIntelligenceCharacterClassifications.ExistingCharacter;
|
||||
}
|
||||
|
||||
if (candidate.Aliases.Count > 0)
|
||||
{
|
||||
return StoryIntelligenceCharacterClassifications.NewAlias;
|
||||
@ -1029,6 +1117,11 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
.Where(word => !string.IsNullOrWhiteSpace(word))
|
||||
.ToList();
|
||||
|
||||
if (words.Count > 1 && IsMergeableArticle(words[0]))
|
||||
{
|
||||
words.RemoveAt(0);
|
||||
}
|
||||
|
||||
if (words.Count > 1 && IsMergeableTitle(words[0]))
|
||||
{
|
||||
words.RemoveAt(0);
|
||||
@ -1037,6 +1130,11 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
return words;
|
||||
}
|
||||
|
||||
private static bool IsMergeableArticle(string value)
|
||||
=> value.Equals("the", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("a", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("an", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static List<string> WordsForFiltering(string? name)
|
||||
=> CleanCharacterName(name)
|
||||
.Replace("/", " ", StringComparison.Ordinal)
|
||||
@ -1083,7 +1181,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
|
||||
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 List<CharacterIndexRow> rows = [];
|
||||
private readonly List<CharacterIndexCharacter> characters = [];
|
||||
@ -1102,16 +1200,37 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
|
||||
public void Add(string? name, int characterId, string characterName)
|
||||
{
|
||||
var key = Normalise(name);
|
||||
var key = Normalise(CleanCharacterName(name));
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
|
||||
if (!byName.TryGetValue(key, out var matches))
|
||||
{
|
||||
matches = [];
|
||||
byName[key] = matches;
|
||||
}
|
||||
|
||||
if (matches.All(match => match.CharacterID != characterId))
|
||||
{
|
||||
matches.Add(new CharacterMatch(characterId, characterName));
|
||||
}
|
||||
|
||||
rows.Add(new CharacterIndexRow(name!, characterName));
|
||||
}
|
||||
}
|
||||
|
||||
public CharacterMatch? Find(string name)
|
||||
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
|
||||
{
|
||||
if (!byName.TryGetValue(Normalise(CleanCharacterName(name)), out var matches))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return matches.Select(match => match.CharacterID).Distinct().Count() == 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
public bool IsAmbiguous(string name)
|
||||
=> byName.TryGetValue(Normalise(CleanCharacterName(name)), out var matches)
|
||||
&& matches.Select(match => match.CharacterID).Distinct().Count() > 1;
|
||||
|
||||
public CharacterMatch? Find(int? characterId)
|
||||
=> characterId.HasValue && byId.TryGetValue(characterId.Value, out var match) ? match : null;
|
||||
@ -1125,14 +1244,46 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
{
|
||||
public string Key { get; } = key;
|
||||
public string DisplayName { get; } = displayName;
|
||||
public int? ExistingCharacterID { get; } = existingCharacterId;
|
||||
public string? ExistingCharacterName { get; } = existingCharacterName;
|
||||
public int? ExistingCharacterID { get; private set; } = existingCharacterId;
|
||||
public string? ExistingCharacterName { get; private set; } = existingCharacterName;
|
||||
public bool HasAmbiguousExistingMatch { get; private set; }
|
||||
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<CharacterAppearanceImport> PresentAppearances { get; } = [];
|
||||
public List<CharacterAppearanceImport> MentionedOnlyAppearances { get; } = [];
|
||||
public decimal? Confidence { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? FirstAppearanceNote { get; set; }
|
||||
|
||||
public void ApplyProposedMatch(CharacterMatch? match)
|
||||
{
|
||||
if (match is null || HasAmbiguousExistingMatch)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ExistingCharacterID.HasValue && ExistingCharacterID.Value != match.CharacterID)
|
||||
{
|
||||
HasAmbiguousExistingMatch = true;
|
||||
ExistingCharacterID = null;
|
||||
ExistingCharacterName = null;
|
||||
return;
|
||||
}
|
||||
|
||||
ExistingCharacterID = match.CharacterID;
|
||||
ExistingCharacterName = match.CharacterName;
|
||||
}
|
||||
|
||||
public void ApplyAmbiguousMatch(bool isAmbiguous)
|
||||
{
|
||||
if (!isAmbiguous)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HasAmbiguousExistingMatch = true;
|
||||
ExistingCharacterID = null;
|
||||
ExistingCharacterName = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CharacterAppearanceImport(
|
||||
|
||||
@ -40,6 +40,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
||||
|
||||
public async Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
{
|
||||
var automatic = await characterImport.ResolveAutomaticMatchesAsync(batch);
|
||||
var review = await characterImport.BuildReviewAsync(batch);
|
||||
await PersistAsync(
|
||||
batch,
|
||||
@ -50,6 +51,16 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
||||
candidate => candidate.Classification,
|
||||
candidate => candidate.ExistingCharacterID,
|
||||
candidate => candidate.PossibleAliases.Take(5));
|
||||
if (automatic.CandidatesResolved > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Persisted Story Intelligence character review omitted automatically resolved identities. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneAppearancesLinked={SceneAppearancesLinked}",
|
||||
batch.BatchID,
|
||||
batch.ProjectID,
|
||||
batch.BookID,
|
||||
automatic.CandidatesResolved,
|
||||
automatic.SceneAppearancesLinked);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
{
|
||||
<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-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="expand">Expand 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">
|
||||
<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>
|
||||
}
|
||||
|
||||
@ -114,11 +114,7 @@
|
||||
</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) />
|
||||
Link existing character
|
||||
</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
|
||||
Same character as existing
|
||||
</label>
|
||||
<label>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<option value="">Choose character...</option>
|
||||
@foreach (var existing in candidate.ExistingCharacterOptions)
|
||||
@ -148,18 +144,6 @@
|
||||
<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" />
|
||||
</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>
|
||||
</details>
|
||||
}
|
||||
@ -202,44 +186,12 @@
|
||||
}
|
||||
};
|
||||
const actionFor = (card) => card.querySelector("[data-character-action]:checked")?.value || "@StoryIntelligenceCharacterImportActions.CreateNew";
|
||||
const isCanonicalTarget = (card) => {
|
||||
const action = actionFor(card);
|
||||
return action !== "@StoryIntelligenceCharacterImportActions.Ignore" && action !== "@StoryIntelligenceCharacterImportActions.Alias";
|
||||
};
|
||||
const updateAliasTargets = () => {
|
||||
const allCards = cards();
|
||||
for (const card of allCards) {
|
||||
const body = card.querySelector("[data-character-key]");
|
||||
const ownKey = body?.getAttribute("data-character-key") || "";
|
||||
const select = card.querySelector("[data-character-alias-target]");
|
||||
if (!select) continue;
|
||||
|
||||
const previous = select.value;
|
||||
for (const option of Array.from(select.options)) {
|
||||
if (!option.value) continue;
|
||||
if (option.dataset.canonical === "true") {
|
||||
option.disabled = false;
|
||||
continue;
|
||||
}
|
||||
const targetCard = allCards.find(item => item.querySelector("[data-character-key]")?.getAttribute("data-character-key") === option.value);
|
||||
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
|
||||
}
|
||||
if (select.selectedOptions[0]?.disabled) {
|
||||
select.value = "";
|
||||
} else {
|
||||
select.value = previous;
|
||||
}
|
||||
}
|
||||
};
|
||||
const updateCard = (card) => {
|
||||
const action = actionFor(card);
|
||||
const namePanel = card.querySelector("[data-character-import-name-panel]");
|
||||
const aliasPanel = card.querySelector("[data-character-alias-panel]");
|
||||
const existingPanel = card.querySelector("[data-character-existing-panel]");
|
||||
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.Alias" || action === "@StoryIntelligenceCharacterImportActions.LinkExisting";
|
||||
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.LinkExisting";
|
||||
if (existingPanel) existingPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.LinkExisting";
|
||||
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.Alias";
|
||||
updateAliasTargets();
|
||||
};
|
||||
document.querySelectorAll("[data-character-bulk]").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
|
||||
@ -14,6 +14,15 @@
|
||||
.flatMap((root) => (root.dataset.storyIntelligenceRunIds || "").split(","))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean))];
|
||||
const diagnosticsEnabled =
|
||||
location.hostname === "localhost"
|
||||
|| location.hostname === "127.0.0.1"
|
||||
|| location.hostname.startsWith("dev.");
|
||||
const log = (message, data) => {
|
||||
if (diagnosticsEnabled && window.console?.debug) {
|
||||
window.console.debug(`[StoryIntelligenceProgress] ${message}`, data || "");
|
||||
}
|
||||
};
|
||||
|
||||
const numberText = (value) => {
|
||||
const number = Number.parseInt(value ?? "0", 10);
|
||||
@ -399,14 +408,17 @@
|
||||
const applyRunProgress = (state) => {
|
||||
const runId = field(state, "runID", "RunID") || field(state, "runId", "RunId");
|
||||
if (!runId) {
|
||||
log("ignored run progress without run id", state);
|
||||
return;
|
||||
}
|
||||
|
||||
let matched = false;
|
||||
onboardingRoots.forEach((root) => {
|
||||
const chapter = root.querySelector(`[data-story-run-id="${runId}"]`);
|
||||
if (!chapter) {
|
||||
return;
|
||||
}
|
||||
matched = true;
|
||||
|
||||
const status = field(state, "status", "Status") || "Pending";
|
||||
const stage = field(state, "currentStage", "CurrentStage") || status;
|
||||
@ -527,6 +539,19 @@
|
||||
|
||||
recalculateOnboardingTotals(root);
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
log("ignored run progress for unwatched run", {
|
||||
runId,
|
||||
watchedRunIds: runIds,
|
||||
payload: state
|
||||
});
|
||||
} else {
|
||||
log("applied run progress", { runId, status: field(state, "status", "Status"), completedScenes: field(state, "completedScenes", "CompletedScenes") });
|
||||
document.dispatchEvent(new CustomEvent("plotdirector:story-intelligence-run-progress-applied", {
|
||||
detail: { runId: String(runId), state }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
@ -537,15 +562,26 @@
|
||||
connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress);
|
||||
connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress);
|
||||
connection.onreconnected(() => {
|
||||
log("SignalR reconnected");
|
||||
legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyLegacyProgress).catch(() => {}));
|
||||
runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)).then(applyRunProgress).catch(() => {}));
|
||||
});
|
||||
connection.onclose(() => {
|
||||
log("SignalR closed; retrying start");
|
||||
startWithRetry();
|
||||
});
|
||||
|
||||
let startRetryTimer = null;
|
||||
const startWithRetry = () => {
|
||||
window.clearTimeout(startRetryTimer);
|
||||
connection.start()
|
||||
.then(() => Promise.all([
|
||||
.then(() => {
|
||||
log("SignalR connected", { runIds, legacyJobIds });
|
||||
return Promise.all([
|
||||
...legacyJobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)),
|
||||
...runIds.map((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)))
|
||||
]))
|
||||
]);
|
||||
})
|
||||
.then((states) => {
|
||||
states.filter(Boolean).forEach((state) => {
|
||||
if (field(state, "runID", "RunID") || field(state, "runId", "RunId")) {
|
||||
@ -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