Fix Story Intelligence character review context
This commit is contained in:
parent
dabf63d91d
commit
7d33323de7
@ -112,9 +112,13 @@ var tests = new (string Name, Action Test)[]
|
||||
("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures),
|
||||
("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired),
|
||||
("Name Intelligence library defaults to all locales", NameIntelligenceLibraryDefaultsToAllLocales),
|
||||
("Story Intelligence new-format context uses canonical characters", StoryIntelligenceNewFormatContextUsesCanonicalCharacters),
|
||||
("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 review omits character metrics dashboard", StoryIntelligenceReviewOmitsCharacterMetricsDashboard),
|
||||
("Story Intelligence resumed chapters use canonical labels", StoryIntelligenceResumedChaptersUseCanonicalLabels),
|
||||
("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch),
|
||||
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
|
||||
("Story Intelligence character review reuses structured appearance aliases", StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases),
|
||||
@ -3018,6 +3022,23 @@ static void NameIntelligenceLibraryDefaultsToAllLocales()
|
||||
Assert(!migration.Contains("COALESCE(@LocaleCode, N'en-US')", StringComparison.Ordinal), "Name search procedure must not convert null locale back to en-US.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceNewFormatContextUsesCanonicalCharacters()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
|
||||
var model = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs"));
|
||||
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
|
||||
|
||||
Assert(service.Contains("BuildCanonicalKnownCharacterContextJsonAsync(preview, review, build)", StringComparison.Ordinal), "New-format Story Intelligence should build canonical known-character context.");
|
||||
Assert(service.Contains("characters.ListCharactersAsync(preview.ProjectID)", StringComparison.Ordinal), "Canonical context should include persisted project characters.");
|
||||
Assert(service.Contains("characters.ListAliasesAsync(character.CharacterID)", StringComparison.Ordinal), "Canonical context should include persisted character aliases.");
|
||||
Assert(service.Contains("characters.ListSceneCharactersAsync(sceneId)", StringComparison.Ordinal), "Canonical context should include deterministic scene-character mappings.");
|
||||
Assert(service.Contains("Source = \"CanonicalCoreImport\"", StringComparison.Ordinal), "New-format known-character context should be marked as canonical Core Import context.");
|
||||
Assert(service.Contains("return BuildKnownCharacterContextJson(preview, review);", StringComparison.Ordinal), "Legacy scan-derived known-character context should remain fallback-only.");
|
||||
Assert(model.Contains("public IReadOnlyList<int> SceneIDs", StringComparison.Ordinal), "Known-character context should carry mapped canonical scene IDs.");
|
||||
Assert(prompt.Contains("do not create a character solely because it appears in `knownCharacters`", StringComparison.Ordinal), "Scene prompt should continue preventing known-character-only creation.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches()
|
||||
{
|
||||
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting,
|
||||
@ -3032,6 +3053,10 @@ static void StoryIntelligenceCharacterDefaultsIgnoreGenericReferences()
|
||||
"Family-role references should default to Ignore.");
|
||||
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("One of his mates", null) == StoryIntelligenceCharacterImportActions.Ignore,
|
||||
"Contextual indefinite references should default to Ignore.");
|
||||
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("creepy guy", null, StoryIntelligenceCharacterClassifications.GenericReference) == StoryIntelligenceCharacterImportActions.Ignore,
|
||||
"Generic classifications should default to Ignore even when the label is descriptive.");
|
||||
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("toddler/boy", null, StoryIntelligenceCharacterClassifications.NonCharacter) == StoryIntelligenceCharacterImportActions.Ignore,
|
||||
"Non-character classifications should default to Ignore.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities()
|
||||
@ -3044,6 +3069,48 @@ static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities()
|
||||
"Named new characters without an existing match should default to Create New.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceAliasDropdownUsesCanonicalCharacters()
|
||||
{
|
||||
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(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("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.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceReviewOmitsCharacterMetricsDashboard()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml"));
|
||||
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
|
||||
|
||||
Assert(!view.Contains("_StoryIntelligencePipelineSummary", StringComparison.Ordinal), "Character Review should not render the large cross-stage metrics dashboard.");
|
||||
Assert(view.Contains("onboarding-compact-status", StringComparison.Ordinal), "Character Review should keep a compact decision summary.");
|
||||
Assert(model.Contains("GenericReferenceCount", StringComparison.Ordinal), "Compact summary should include useful candidate classification counts.");
|
||||
Assert(view.IndexOf("story-character-card-grid", StringComparison.Ordinal) > view.IndexOf("onboarding-compact-status", StringComparison.Ordinal), "Candidate cards should appear directly after compact context.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceResumedChaptersUseCanonicalLabels()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
|
||||
|
||||
Assert(service.Contains("chapters.ListByBookAsync(bookId)", StringComparison.Ordinal), "Resume should load canonical chapters for the book.");
|
||||
Assert(service.Contains("CanonicalChapterTitle(chapterLookup.GetValueOrDefault(run.ChapterID)", StringComparison.Ordinal), "Committed runs should display canonical chapter titles.");
|
||||
Assert(service.Contains("CanonicalChapterTitle(run.ChapterID.HasValue ? chapterLookup.GetValueOrDefault(run.ChapterID.Value) : null, run.ChapterNumber)", StringComparison.Ordinal), "Active runs should display canonical chapter titles.");
|
||||
Assert(!service.Contains("FirstConfigured(run.SourceFileName, run.CurrentMessage", StringComparison.Ordinal), "Resume should never prefer Word document filenames as chapter titles.");
|
||||
Assert(service.Contains("return title.StartsWith(\"Chapter \", StringComparison.OrdinalIgnoreCase) ? title : $\"{fallback} - {title}\"", StringComparison.Ordinal), "Blank titles should fall back to Chapter N and nonblank titles should include chapter number.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceCharacterReviewPostsSelectedExistingMatch()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
|
||||
@ -177,6 +177,7 @@ public sealed class StoryIntelligenceKnownCharacterContext
|
||||
public string SexHint { get; init; } = "Unknown";
|
||||
public int? ExistingCharacterID { get; init; }
|
||||
public IReadOnlyList<string> CandidateKeys { get; init; } = [];
|
||||
public IReadOnlyList<int> SceneIDs { get; init; } = [];
|
||||
public int MentionCount { get; init; }
|
||||
public decimal Confidence { get; init; }
|
||||
public string Source { get; init; } = "InitialManuscriptScan";
|
||||
|
||||
@ -46,6 +46,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
IStoryIntelligencePipelineStateService pipelineState,
|
||||
IStoryMemoryRepository storyMemory,
|
||||
IBookRepository books,
|
||||
IChapterRepository chapters,
|
||||
ICharacterRepository characters,
|
||||
IStoryIntelligenceClient client,
|
||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||
IStoryIntelligenceProgressNotifier notifier,
|
||||
@ -131,7 +133,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
clientStatus.ChapterStructureModel,
|
||||
clientStatus.SceneIntelligenceModel);
|
||||
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
|
||||
var knownCharactersJson = BuildKnownCharacterContextJson(preview, review);
|
||||
var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(preview, review, build);
|
||||
|
||||
foreach (var chapter in included)
|
||||
{
|
||||
@ -158,6 +160,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
}
|
||||
|
||||
var paragraphs = StoryIntelligenceParagraphs.Split(text);
|
||||
var canonicalChapterTitle = CanonicalChapterTitle(await chapters.GetAsync(chapterId), chapter.Decision.ChapterNumber);
|
||||
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
|
||||
{
|
||||
UserID = userId,
|
||||
@ -166,7 +169,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
ChapterID = chapterId,
|
||||
ChapterNumber = chapter.Decision.ChapterNumber,
|
||||
SourceType = SourceType,
|
||||
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}",
|
||||
SourceLabel = $"{wizard.SelectedBookTitle}: {canonicalChapterTitle}",
|
||||
ChapterText = text,
|
||||
SourceFileName = preview.DocumentTitle,
|
||||
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
|
||||
@ -182,7 +185,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
{
|
||||
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
|
||||
ChapterNumber = chapter.Decision.ChapterNumber,
|
||||
ChapterTitle = chapter.Decision.Title,
|
||||
ChapterTitle = canonicalChapterTitle,
|
||||
ChapterID = chapterId,
|
||||
RunID = runId
|
||||
});
|
||||
@ -266,6 +269,68 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)];
|
||||
}
|
||||
|
||||
private async Task<string?> BuildCanonicalKnownCharacterContextJsonAsync(
|
||||
ManuscriptScanPreview preview,
|
||||
ManuscriptScanReviewDecision review,
|
||||
OnboardingManuscriptBuildResult build)
|
||||
{
|
||||
if (build.SceneMappings.Count == 0)
|
||||
{
|
||||
return BuildKnownCharacterContextJson(preview, review);
|
||||
}
|
||||
|
||||
var allCharacters = (await characters.ListCharactersAsync(preview.ProjectID))
|
||||
.Where(character => !character.IsArchived)
|
||||
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (allCharacters.Count == 0)
|
||||
{
|
||||
return BuildKnownCharacterContextJson(preview, review);
|
||||
}
|
||||
|
||||
var sceneIds = build.SceneMappings.Select(mapping => mapping.SceneID).Distinct().ToList();
|
||||
var sceneIdsByCharacter = new Dictionary<int, HashSet<int>>();
|
||||
foreach (var sceneId in sceneIds)
|
||||
{
|
||||
foreach (var sceneCharacter in await characters.ListSceneCharactersAsync(sceneId))
|
||||
{
|
||||
if (!sceneIdsByCharacter.TryGetValue(sceneCharacter.CharacterID, out var mappedScenes))
|
||||
{
|
||||
mappedScenes = [];
|
||||
sceneIdsByCharacter[sceneCharacter.CharacterID] = mappedScenes;
|
||||
}
|
||||
|
||||
mappedScenes.Add(sceneId);
|
||||
}
|
||||
}
|
||||
|
||||
var context = new List<StoryIntelligenceKnownCharacterContext>();
|
||||
foreach (var character in allCharacters)
|
||||
{
|
||||
var aliases = (await characters.ListAliasesAsync(character.CharacterID))
|
||||
.Select(alias => alias.Alias)
|
||||
.Where(alias => !string.IsNullOrWhiteSpace(alias))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var mappedScenes = sceneIdsByCharacter.GetValueOrDefault(character.CharacterID)?.OrderBy(id => id).ToList() ?? [];
|
||||
context.Add(new StoryIntelligenceKnownCharacterContext
|
||||
{
|
||||
CanonicalName = character.CharacterName,
|
||||
Aliases = aliases,
|
||||
SexHint = CharacterSexHintService.Normalise(character.Sex, character.CharacterName),
|
||||
ExistingCharacterID = character.CharacterID,
|
||||
CandidateKeys = [],
|
||||
SceneIDs = mappedScenes,
|
||||
MentionCount = mappedScenes.Count,
|
||||
Confidence = mappedScenes.Count > 0 ? 1m : 0.8m,
|
||||
Source = "CanonicalCoreImport"
|
||||
});
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
}
|
||||
|
||||
public static string? BuildKnownCharacterContextJson(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
|
||||
{
|
||||
var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
|
||||
@ -755,6 +820,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var chapterLookup = (await chapters.ListByBookAsync(bookId)).ToDictionary(chapter => chapter.ChapterID);
|
||||
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
|
||||
.OrderBy(run => run.ChapterNumber)
|
||||
.ThenBy(run => run.StoryIntelligenceRunID)
|
||||
@ -762,7 +828,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
{
|
||||
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
|
||||
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
|
||||
ChapterTitle = run.ChapterTitle,
|
||||
ChapterTitle = CanonicalChapterTitle(chapterLookup.GetValueOrDefault(run.ChapterID), run.ChapterNumber),
|
||||
ChapterID = run.ChapterID,
|
||||
RunID = run.StoryIntelligenceRunID
|
||||
})
|
||||
@ -777,7 +843,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
{
|
||||
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
|
||||
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
|
||||
ChapterTitle = FirstConfigured(run.SourceFileName, run.CurrentMessage, run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : $"Run {run.StoryIntelligenceRunID:N0}")!,
|
||||
ChapterTitle = CanonicalChapterTitle(run.ChapterID.HasValue ? chapterLookup.GetValueOrDefault(run.ChapterID.Value) : null, run.ChapterNumber),
|
||||
ChapterID = run.ChapterID ?? 0,
|
||||
RunID = run.StoryIntelligenceRunID
|
||||
})
|
||||
@ -909,6 +975,19 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
private int RequireUserId()
|
||||
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in before using Story Intelligence.");
|
||||
|
||||
private static string CanonicalChapterTitle(Chapter? chapter, decimal? chapterNumber)
|
||||
{
|
||||
var number = chapterNumber ?? chapter?.ChapterNumber;
|
||||
var fallback = number.HasValue && number.Value > 0 ? $"Chapter {number.Value:0.##}" : "Chapter";
|
||||
var title = CleanContext(chapter?.ChapterTitle).TrimStart('-', ' ', '\t');
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return title.StartsWith("Chapter ", StringComparison.OrdinalIgnoreCase) ? title : $"{fallback} - {title}";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IncludedChapter> IncludedChapters(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
|
||||
{
|
||||
var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
||||
|
||||
@ -18,6 +18,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
ISceneRepository scenes,
|
||||
IStoryIntelligencePipelineStateService pipelineState,
|
||||
INameIntelligenceService nameIntelligence,
|
||||
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
||||
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
@ -79,7 +80,10 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
CharacterID = character.CharacterID,
|
||||
CharacterName = character.CharacterName
|
||||
})
|
||||
.OrderBy(character => character.CharacterName)
|
||||
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var classified = visibleCandidates
|
||||
.Select(candidate => new { Candidate = candidate, Classification = ClassifyCandidate(candidate) })
|
||||
.ToList();
|
||||
return new StoryIntelligenceCharacterReviewViewModel
|
||||
{
|
||||
@ -87,21 +91,27 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
CanImport = visibleCandidates.Count > 0,
|
||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||||
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||||
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
|
||||
GenericReferenceCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.GenericReference),
|
||||
ExistingMatchCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.ExistingCharacter),
|
||||
NewCandidateCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.NewCharacter || item.Classification == StoryIntelligenceCharacterClassifications.NewAlias),
|
||||
AmbiguousCount = classified.Count(item => item.Classification == StoryIntelligenceCharacterClassifications.Ambiguous),
|
||||
AliasTargetOptions = existingOptions,
|
||||
Candidates = classified.Select(item => new StoryIntelligenceCharacterReviewCandidateViewModel
|
||||
{
|
||||
DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(candidate.DisplayName, candidate.ExistingCharacterID),
|
||||
Key = candidate.Key,
|
||||
CharacterName = candidate.DisplayName,
|
||||
ImportName = candidate.DisplayName,
|
||||
AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
|
||||
Confidence = ConfidenceBand(candidate.Confidence),
|
||||
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
|
||||
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
||||
ExistingCharacterID = candidate.ExistingCharacterID,
|
||||
ExistingCharacterName = candidate.ExistingCharacterName,
|
||||
SelectedExistingCharacterID = candidate.ExistingCharacterID,
|
||||
DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(item.Candidate.DisplayName, item.Candidate.ExistingCharacterID, item.Classification),
|
||||
Classification = item.Classification,
|
||||
Key = item.Candidate.Key,
|
||||
CharacterName = item.Candidate.DisplayName,
|
||||
ImportName = item.Candidate.DisplayName,
|
||||
AppearsInScenes = item.Candidate.PresentAppearances.Concat(item.Candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
|
||||
Confidence = ConfidenceBand(item.Candidate.Confidence),
|
||||
PossibleAliases = item.Candidate.Aliases.OrderBy(value => value).ToList(),
|
||||
ExampleFirstAppearance = item.Candidate.FirstAppearanceNote,
|
||||
ExistingCharacterID = item.Candidate.ExistingCharacterID,
|
||||
ExistingCharacterName = item.Candidate.ExistingCharacterName,
|
||||
SelectedExistingCharacterID = item.Candidate.ExistingCharacterID,
|
||||
ExistingCharacterOptions = existingOptions,
|
||||
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(candidate.Key)
|
||||
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(item.Candidate.Key)
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
@ -277,29 +287,42 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetKey = Clean(choice.AliasTargetKey);
|
||||
if (string.IsNullOrWhiteSpace(targetKey)
|
||||
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|
||||
|| !choices.TryGetValue(targetKey, out var targetChoice)
|
||||
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
||||
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|
||||
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetCharacterId))
|
||||
var target = existingIndex.Find(choice.AliasTargetCharacterID);
|
||||
CharacterCandidate? targetCandidate = null;
|
||||
var targetCharacterId = target?.CharacterID;
|
||||
var targetName = target?.CharacterName;
|
||||
if (!targetCharacterId.HasValue)
|
||||
{
|
||||
var targetKey = Clean(choice.AliasTargetKey);
|
||||
if (!string.IsNullOrWhiteSpace(targetKey)
|
||||
&& !string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|
||||
&& choices.TryGetValue(targetKey, out var targetChoice)
|
||||
&& !string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
||||
&& candidateByKey.TryGetValue(targetKey, out targetCandidate)
|
||||
&& resolvedCandidateIds.TryGetValue(targetKey, out var resolvedTargetCharacterId))
|
||||
{
|
||||
targetCharacterId = resolvedTargetCharacterId;
|
||||
targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetCharacterId.HasValue)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Story Intelligence character alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
|
||||
"Story Intelligence character alias candidate {CandidateKey} could not be imported because no canonical target was selected. AliasTargetCharacterID={AliasTargetCharacterID}",
|
||||
candidate.Key,
|
||||
targetKey);
|
||||
choice.AliasTargetCharacterID);
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
|
||||
await TryAddAliasAsync(targetCharacterId, candidate.DisplayName, targetName);
|
||||
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId);
|
||||
targetName ??= $"Character {targetCharacterId.Value:N0}";
|
||||
await TryAddAliasAsync(targetCharacterId.Value, candidate.DisplayName, targetName);
|
||||
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId.Value);
|
||||
foreach (var alias in candidate.Aliases)
|
||||
{
|
||||
await TryAddAliasAsync(targetCharacterId, alias, targetName);
|
||||
AddResolvedName(resolvedCharacters, alias, targetCharacterId);
|
||||
await TryAddAliasAsync(targetCharacterId.Value, alias, targetName);
|
||||
AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value);
|
||||
}
|
||||
|
||||
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
|
||||
@ -307,16 +330,16 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
await characters.SaveSceneCharacterAsync(new SceneCharacter
|
||||
{
|
||||
SceneID = appearance.SceneID,
|
||||
CharacterID = targetCharacterId,
|
||||
CharacterID = targetCharacterId.Value,
|
||||
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
||||
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
||||
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetCandidate.DisplayName, appearance.Notes)
|
||||
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetName, appearance.Notes)
|
||||
});
|
||||
linkedAppearances++;
|
||||
}
|
||||
|
||||
aliased++;
|
||||
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId, true);
|
||||
AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Alias, candidate.DisplayName, targetCharacterId.Value, true);
|
||||
}
|
||||
|
||||
foreach (var scene in data.SceneAnalyses)
|
||||
@ -341,23 +364,26 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
PovLinksResolved = povLinks,
|
||||
ScenePeoplePanelsUpdated = linkedAppearances
|
||||
};
|
||||
var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID);
|
||||
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
||||
|
||||
logger.LogInformation(
|
||||
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",
|
||||
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliased={Aliased} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks} RemappedScenes={RemappedScenes} RemappedLinks={RemappedLinks}",
|
||||
batch.BatchID,
|
||||
created,
|
||||
linkedExisting,
|
||||
aliased,
|
||||
ignored,
|
||||
linkedAppearances,
|
||||
povLinks);
|
||||
povLinks,
|
||||
remap.ScenesScanned,
|
||||
remap.LinksSaved);
|
||||
|
||||
return new StoryIntelligenceImportCommitResult
|
||||
{
|
||||
Success = true,
|
||||
ScenesCreated = linkedAppearances,
|
||||
Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {aliased:N0} alias decision(s) applied, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved."
|
||||
Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {aliased:N0} alias decision(s) applied, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved. Deterministic remapping updated {remap.LinksSaved:N0} link(s)."
|
||||
};
|
||||
}
|
||||
|
||||
@ -793,6 +819,39 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
return !IsGenericGroupReference(name);
|
||||
}
|
||||
|
||||
private static string ClassifyCandidate(CharacterCandidate candidate)
|
||||
{
|
||||
var name = CleanCharacterName(candidate.DisplayName);
|
||||
if (!IsCharacterNameCandidate(name))
|
||||
{
|
||||
return StoryIntelligenceCharacterClassifications.NonCharacter;
|
||||
}
|
||||
|
||||
if (candidate.ExistingCharacterID.HasValue)
|
||||
{
|
||||
return StoryIntelligenceCharacterClassifications.ExistingCharacter;
|
||||
}
|
||||
|
||||
if (StoryIntelligenceCharacterReviewDefaults.IsGenericContextualReference(name)
|
||||
|| IsFamilyRoleOnly(name)
|
||||
|| IsGenericGroupReference(name))
|
||||
{
|
||||
return StoryIntelligenceCharacterClassifications.GenericReference;
|
||||
}
|
||||
|
||||
if (candidate.Aliases.Count > 0)
|
||||
{
|
||||
return StoryIntelligenceCharacterClassifications.NewAlias;
|
||||
}
|
||||
|
||||
if (candidate.Confidence is null or < 0.65m)
|
||||
{
|
||||
return StoryIntelligenceCharacterClassifications.Ambiguous;
|
||||
}
|
||||
|
||||
return StoryIntelligenceCharacterClassifications.NewCharacter;
|
||||
}
|
||||
|
||||
private static bool IsCharacterNameCandidate(string? name)
|
||||
{
|
||||
var clean = CleanCharacterName(name);
|
||||
|
||||
@ -13,14 +13,16 @@ public static class StoryIntelligenceCharacterReviewDefaults
|
||||
@"^(one of (them|his mates|her mates|their mates|the men|the women|the boys|the girls)|his mate|her mate|their mate|his friend|her friend|their friend)$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static string DetermineAction(string? candidateName, int? proposedExistingCharacterId)
|
||||
public static string DetermineAction(string? candidateName, int? proposedExistingCharacterId, string? classification = null)
|
||||
{
|
||||
if (proposedExistingCharacterId.HasValue)
|
||||
{
|
||||
return StoryIntelligenceCharacterImportActions.LinkExisting;
|
||||
}
|
||||
|
||||
if (IsGenericContextualReference(candidateName))
|
||||
if (string.Equals(classification, StoryIntelligenceCharacterClassifications.GenericReference, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(classification, StoryIntelligenceCharacterClassifications.NonCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
|| IsGenericContextualReference(candidateName))
|
||||
{
|
||||
return StoryIntelligenceCharacterImportActions.Ignore;
|
||||
}
|
||||
|
||||
@ -273,7 +273,12 @@ public sealed class StoryIntelligenceCharacterReviewViewModel
|
||||
public bool HasCommittedScenes { get; init; }
|
||||
public int AlreadyLinkedCount { get; init; }
|
||||
public bool IsComplete { get; init; }
|
||||
public int GenericReferenceCount { get; init; }
|
||||
public int ExistingMatchCount { get; init; }
|
||||
public int NewCandidateCount { get; init; }
|
||||
public int AmbiguousCount { get; init; }
|
||||
public IReadOnlyList<StoryIntelligenceCharacterReviewCandidateViewModel> Candidates { get; init; } = [];
|
||||
public IReadOnlyList<StoryIntelligenceCharacterExistingOptionViewModel> AliasTargetOptions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
|
||||
@ -282,6 +287,7 @@ public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
|
||||
public string CharacterName { get; init; } = string.Empty;
|
||||
public string ImportName { get; init; } = string.Empty;
|
||||
public string DefaultAction { get; init; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
||||
public string Classification { get; init; } = StoryIntelligenceCharacterClassifications.NewCharacter;
|
||||
public int AppearsInScenes { get; init; }
|
||||
public string Confidence { get; init; } = "Unknown";
|
||||
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
|
||||
@ -314,9 +320,20 @@ public sealed class StoryIntelligenceCharacterImportChoiceForm
|
||||
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
||||
public string? ImportName { get; set; }
|
||||
public int? ExistingCharacterID { get; set; }
|
||||
public int? AliasTargetCharacterID { get; set; }
|
||||
public string? AliasTargetKey { get; set; }
|
||||
}
|
||||
|
||||
public static class StoryIntelligenceCharacterClassifications
|
||||
{
|
||||
public const string ExistingCharacter = "ExistingCharacter";
|
||||
public const string NewAlias = "NewAlias";
|
||||
public const string NewCharacter = "NewCharacter";
|
||||
public const string GenericReference = "GenericReference";
|
||||
public const string Ambiguous = "Ambiguous";
|
||||
public const string NonCharacter = "NonCharacter";
|
||||
}
|
||||
|
||||
public static class StoryIntelligenceCharacterImportActions
|
||||
{
|
||||
public const string CreateNew = "CreateNew";
|
||||
|
||||
@ -23,7 +23,13 @@
|
||||
<div class="alert alert-success">@message</div>
|
||||
}
|
||||
|
||||
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" />
|
||||
<p class="onboarding-compact-status">
|
||||
@Model.CharacterReview.Candidates.Count.ToString("N0") candidate@(Model.CharacterReview.Candidates.Count == 1 ? string.Empty : "s")
|
||||
· @Model.CharacterReview.ExistingMatchCount.ToString("N0") existing match@(Model.CharacterReview.ExistingMatchCount == 1 ? string.Empty : "es")
|
||||
· @Model.CharacterReview.NewCandidateCount.ToString("N0") new or alias
|
||||
· @Model.CharacterReview.AmbiguousCount.ToString("N0") ambiguous
|
||||
· @Model.CharacterReview.GenericReferenceCount.ToString("N0") generic
|
||||
</p>
|
||||
|
||||
<section class="story-review-chapter-list" aria-label="Character review">
|
||||
@if (Model.CharacterReview.Candidates.Count == 0)
|
||||
@ -63,7 +69,7 @@
|
||||
<summary>
|
||||
<span>
|
||||
<strong>@candidate.CharacterName</strong>
|
||||
<small>@candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
|
||||
<small>@candidate.Classification · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
|
||||
</span>
|
||||
<em>@candidate.Confidence</em>
|
||||
</summary>
|
||||
@ -134,11 +140,11 @@
|
||||
|
||||
<div data-character-alias-panel hidden>
|
||||
<label class="form-label" for="character-alias-target-@i">Alias target</label>
|
||||
<select id="character-alias-target-@i" class="form-select" name="Characters[@i].AliasTargetKey" data-character-alias-target>
|
||||
<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.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
|
||||
@foreach (var target in Model.CharacterReview.AliasTargetOptions)
|
||||
{
|
||||
<option value="@target.Key">@target.CharacterName@(target.IsExistingMatch ? $" -> {target.ExistingCharacterName}" : string.Empty)</option>
|
||||
<option value="@target.CharacterID" data-canonical="true">@target.CharacterName</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@ -187,6 +193,10 @@
|
||||
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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user