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 repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures),
|
||||||
("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired),
|
("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired),
|
||||||
("Name Intelligence library defaults to all locales", NameIntelligenceLibraryDefaultsToAllLocales),
|
("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 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 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 review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch),
|
||||||
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
|
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
|
||||||
("Story Intelligence character review reuses structured appearance aliases", StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases),
|
("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.");
|
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()
|
static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches()
|
||||||
{
|
{
|
||||||
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting,
|
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting,
|
||||||
@ -3032,6 +3053,10 @@ static void StoryIntelligenceCharacterDefaultsIgnoreGenericReferences()
|
|||||||
"Family-role references should default to Ignore.");
|
"Family-role references should default to Ignore.");
|
||||||
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("One of his mates", null) == StoryIntelligenceCharacterImportActions.Ignore,
|
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("One of his mates", null) == StoryIntelligenceCharacterImportActions.Ignore,
|
||||||
"Contextual indefinite references should default to 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()
|
static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities()
|
||||||
@ -3044,6 +3069,48 @@ 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()
|
||||||
|
{
|
||||||
|
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()
|
static void StoryIntelligenceCharacterReviewPostsSelectedExistingMatch()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
|||||||
@ -177,6 +177,7 @@ public sealed class StoryIntelligenceKnownCharacterContext
|
|||||||
public string SexHint { get; init; } = "Unknown";
|
public string SexHint { get; init; } = "Unknown";
|
||||||
public int? ExistingCharacterID { get; init; }
|
public int? ExistingCharacterID { get; init; }
|
||||||
public IReadOnlyList<string> CandidateKeys { get; init; } = [];
|
public IReadOnlyList<string> CandidateKeys { get; init; } = [];
|
||||||
|
public IReadOnlyList<int> SceneIDs { get; init; } = [];
|
||||||
public int MentionCount { get; init; }
|
public int MentionCount { get; init; }
|
||||||
public decimal Confidence { get; init; }
|
public decimal Confidence { get; init; }
|
||||||
public string Source { get; init; } = "InitialManuscriptScan";
|
public string Source { get; init; } = "InitialManuscriptScan";
|
||||||
|
|||||||
@ -46,6 +46,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
IStoryMemoryRepository storyMemory,
|
IStoryMemoryRepository storyMemory,
|
||||||
IBookRepository books,
|
IBookRepository books,
|
||||||
|
IChapterRepository chapters,
|
||||||
|
ICharacterRepository characters,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||||
IStoryIntelligenceProgressNotifier notifier,
|
IStoryIntelligenceProgressNotifier notifier,
|
||||||
@ -131,7 +133,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
clientStatus.ChapterStructureModel,
|
clientStatus.ChapterStructureModel,
|
||||||
clientStatus.SceneIntelligenceModel);
|
clientStatus.SceneIntelligenceModel);
|
||||||
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
|
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
|
||||||
var knownCharactersJson = BuildKnownCharacterContextJson(preview, review);
|
var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(preview, review, build);
|
||||||
|
|
||||||
foreach (var chapter in included)
|
foreach (var chapter in included)
|
||||||
{
|
{
|
||||||
@ -158,6 +160,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var paragraphs = StoryIntelligenceParagraphs.Split(text);
|
var paragraphs = StoryIntelligenceParagraphs.Split(text);
|
||||||
|
var canonicalChapterTitle = CanonicalChapterTitle(await chapters.GetAsync(chapterId), chapter.Decision.ChapterNumber);
|
||||||
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
|
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
|
||||||
{
|
{
|
||||||
UserID = userId,
|
UserID = userId,
|
||||||
@ -166,7 +169,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
ChapterID = chapterId,
|
ChapterID = chapterId,
|
||||||
ChapterNumber = chapter.Decision.ChapterNumber,
|
ChapterNumber = chapter.Decision.ChapterNumber,
|
||||||
SourceType = SourceType,
|
SourceType = SourceType,
|
||||||
SourceLabel = $"{wizard.SelectedBookTitle}: {chapter.Decision.Title}",
|
SourceLabel = $"{wizard.SelectedBookTitle}: {canonicalChapterTitle}",
|
||||||
ChapterText = text,
|
ChapterText = text,
|
||||||
SourceFileName = preview.DocumentTitle,
|
SourceFileName = preview.DocumentTitle,
|
||||||
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
|
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
|
||||||
@ -182,7 +185,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
{
|
{
|
||||||
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
|
TemporaryChapterKey = chapter.Preview.TemporaryChapterKey,
|
||||||
ChapterNumber = chapter.Decision.ChapterNumber,
|
ChapterNumber = chapter.Decision.ChapterNumber,
|
||||||
ChapterTitle = chapter.Decision.Title,
|
ChapterTitle = canonicalChapterTitle,
|
||||||
ChapterID = chapterId,
|
ChapterID = chapterId,
|
||||||
RunID = runId
|
RunID = runId
|
||||||
});
|
});
|
||||||
@ -266,6 +269,68 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)];
|
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)
|
public static string? BuildKnownCharacterContextJson(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
|
||||||
{
|
{
|
||||||
var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
|
var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
|
||||||
@ -755,6 +820,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var chapterLookup = (await chapters.ListByBookAsync(bookId)).ToDictionary(chapter => chapter.ChapterID);
|
||||||
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
|
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
|
||||||
.OrderBy(run => run.ChapterNumber)
|
.OrderBy(run => run.ChapterNumber)
|
||||||
.ThenBy(run => run.StoryIntelligenceRunID)
|
.ThenBy(run => run.StoryIntelligenceRunID)
|
||||||
@ -762,7 +828,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
{
|
{
|
||||||
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
|
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
|
||||||
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
|
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
|
||||||
ChapterTitle = run.ChapterTitle,
|
ChapterTitle = CanonicalChapterTitle(chapterLookup.GetValueOrDefault(run.ChapterID), run.ChapterNumber),
|
||||||
ChapterID = run.ChapterID,
|
ChapterID = run.ChapterID,
|
||||||
RunID = run.StoryIntelligenceRunID
|
RunID = run.StoryIntelligenceRunID
|
||||||
})
|
})
|
||||||
@ -777,7 +843,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
{
|
{
|
||||||
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
|
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
|
||||||
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
|
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,
|
ChapterID = run.ChapterID ?? 0,
|
||||||
RunID = run.StoryIntelligenceRunID
|
RunID = run.StoryIntelligenceRunID
|
||||||
})
|
})
|
||||||
@ -909,6 +975,19 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
private int RequireUserId()
|
private int RequireUserId()
|
||||||
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in before using Story Intelligence.");
|
=> 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)
|
private static IReadOnlyList<IncludedChapter> IncludedChapters(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
|
||||||
{
|
{
|
||||||
var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
var chapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
||||||
|
|||||||
@ -18,6 +18,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
ISceneRepository scenes,
|
ISceneRepository scenes,
|
||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
INameIntelligenceService nameIntelligence,
|
INameIntelligenceService nameIntelligence,
|
||||||
|
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
||||||
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
|
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
@ -79,7 +80,10 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
CharacterID = character.CharacterID,
|
CharacterID = character.CharacterID,
|
||||||
CharacterName = character.CharacterName
|
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();
|
.ToList();
|
||||||
return new StoryIntelligenceCharacterReviewViewModel
|
return new StoryIntelligenceCharacterReviewViewModel
|
||||||
{
|
{
|
||||||
@ -87,21 +91,27 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
CanImport = visibleCandidates.Count > 0,
|
CanImport = visibleCandidates.Count > 0,
|
||||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||||||
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
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),
|
DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(item.Candidate.DisplayName, item.Candidate.ExistingCharacterID, item.Classification),
|
||||||
Key = candidate.Key,
|
Classification = item.Classification,
|
||||||
CharacterName = candidate.DisplayName,
|
Key = item.Candidate.Key,
|
||||||
ImportName = candidate.DisplayName,
|
CharacterName = item.Candidate.DisplayName,
|
||||||
AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
|
ImportName = item.Candidate.DisplayName,
|
||||||
Confidence = ConfidenceBand(candidate.Confidence),
|
AppearsInScenes = item.Candidate.PresentAppearances.Concat(item.Candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(),
|
||||||
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
|
Confidence = ConfidenceBand(item.Candidate.Confidence),
|
||||||
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
PossibleAliases = item.Candidate.Aliases.OrderBy(value => value).ToList(),
|
||||||
ExistingCharacterID = candidate.ExistingCharacterID,
|
ExampleFirstAppearance = item.Candidate.FirstAppearanceNote,
|
||||||
ExistingCharacterName = candidate.ExistingCharacterName,
|
ExistingCharacterID = item.Candidate.ExistingCharacterID,
|
||||||
SelectedExistingCharacterID = candidate.ExistingCharacterID,
|
ExistingCharacterName = item.Candidate.ExistingCharacterName,
|
||||||
|
SelectedExistingCharacterID = item.Candidate.ExistingCharacterID,
|
||||||
ExistingCharacterOptions = existingOptions,
|
ExistingCharacterOptions = existingOptions,
|
||||||
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(candidate.Key)
|
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(item.Candidate.Key)
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -277,29 +287,42 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetKey = Clean(choice.AliasTargetKey);
|
var target = existingIndex.Find(choice.AliasTargetCharacterID);
|
||||||
if (string.IsNullOrWhiteSpace(targetKey)
|
CharacterCandidate? targetCandidate = null;
|
||||||
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|
var targetCharacterId = target?.CharacterID;
|
||||||
|| !choices.TryGetValue(targetKey, out var targetChoice)
|
var targetName = target?.CharacterName;
|
||||||
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
if (!targetCharacterId.HasValue)
|
||||||
|| string.Equals(targetChoice.Action, StoryIntelligenceCharacterImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
{
|
||||||
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|
var targetKey = Clean(choice.AliasTargetKey);
|
||||||
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetCharacterId))
|
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(
|
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,
|
candidate.Key,
|
||||||
targetKey);
|
choice.AliasTargetCharacterID);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetName = targetCandidate.ExistingCharacterName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
|
targetName ??= $"Character {targetCharacterId.Value:N0}";
|
||||||
await TryAddAliasAsync(targetCharacterId, candidate.DisplayName, targetName);
|
await TryAddAliasAsync(targetCharacterId.Value, candidate.DisplayName, targetName);
|
||||||
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId);
|
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId.Value);
|
||||||
foreach (var alias in candidate.Aliases)
|
foreach (var alias in candidate.Aliases)
|
||||||
{
|
{
|
||||||
await TryAddAliasAsync(targetCharacterId, alias, targetName);
|
await TryAddAliasAsync(targetCharacterId.Value, alias, targetName);
|
||||||
AddResolvedName(resolvedCharacters, alias, targetCharacterId);
|
AddResolvedName(resolvedCharacters, alias, targetCharacterId.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var appearance in candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Where(appearance => !appearance.AlreadyLinked))
|
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
|
await characters.SaveSceneCharacterAsync(new SceneCharacter
|
||||||
{
|
{
|
||||||
SceneID = appearance.SceneID,
|
SceneID = appearance.SceneID,
|
||||||
CharacterID = targetCharacterId,
|
CharacterID = targetCharacterId.Value,
|
||||||
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene),
|
||||||
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly),
|
||||||
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetCandidate.DisplayName, appearance.Notes)
|
AppearanceNotes = BuildAliasAppearanceNote(candidate.DisplayName, targetName, appearance.Notes)
|
||||||
});
|
});
|
||||||
linkedAppearances++;
|
linkedAppearances++;
|
||||||
}
|
}
|
||||||
|
|
||||||
aliased++;
|
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)
|
foreach (var scene in data.SceneAnalyses)
|
||||||
@ -341,23 +364,26 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
PovLinksResolved = povLinks,
|
PovLinksResolved = povLinks,
|
||||||
ScenePeoplePanelsUpdated = linkedAppearances
|
ScenePeoplePanelsUpdated = linkedAppearances
|
||||||
};
|
};
|
||||||
|
var remap = await coreImportCharacterMapping.MapBookAsync(batch.ProjectID, batch.BookID);
|
||||||
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
|
||||||
|
|
||||||
logger.LogInformation(
|
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,
|
batch.BatchID,
|
||||||
created,
|
created,
|
||||||
linkedExisting,
|
linkedExisting,
|
||||||
aliased,
|
aliased,
|
||||||
ignored,
|
ignored,
|
||||||
linkedAppearances,
|
linkedAppearances,
|
||||||
povLinks);
|
povLinks,
|
||||||
|
remap.ScenesScanned,
|
||||||
|
remap.LinksSaved);
|
||||||
|
|
||||||
return new StoryIntelligenceImportCommitResult
|
return new StoryIntelligenceImportCommitResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
ScenesCreated = linkedAppearances,
|
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);
|
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)
|
private static bool IsCharacterNameCandidate(string? name)
|
||||||
{
|
{
|
||||||
var clean = CleanCharacterName(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)$",
|
@"^(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);
|
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)
|
if (proposedExistingCharacterId.HasValue)
|
||||||
{
|
{
|
||||||
return StoryIntelligenceCharacterImportActions.LinkExisting;
|
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;
|
return StoryIntelligenceCharacterImportActions.Ignore;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -273,7 +273,12 @@ public sealed class StoryIntelligenceCharacterReviewViewModel
|
|||||||
public bool HasCommittedScenes { get; init; }
|
public bool HasCommittedScenes { get; init; }
|
||||||
public int AlreadyLinkedCount { get; init; }
|
public int AlreadyLinkedCount { get; init; }
|
||||||
public bool IsComplete { 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<StoryIntelligenceCharacterReviewCandidateViewModel> Candidates { get; init; } = [];
|
||||||
|
public IReadOnlyList<StoryIntelligenceCharacterExistingOptionViewModel> AliasTargetOptions { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
|
public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
|
||||||
@ -282,6 +287,7 @@ public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
|
|||||||
public string CharacterName { get; init; } = string.Empty;
|
public string CharacterName { get; init; } = string.Empty;
|
||||||
public string ImportName { get; init; } = string.Empty;
|
public string ImportName { get; init; } = string.Empty;
|
||||||
public string DefaultAction { get; init; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
public string DefaultAction { get; init; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
||||||
|
public string Classification { get; init; } = StoryIntelligenceCharacterClassifications.NewCharacter;
|
||||||
public int AppearsInScenes { get; init; }
|
public int AppearsInScenes { get; init; }
|
||||||
public string Confidence { get; init; } = "Unknown";
|
public string Confidence { get; init; } = "Unknown";
|
||||||
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
|
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
|
||||||
@ -314,9 +320,20 @@ public sealed class StoryIntelligenceCharacterImportChoiceForm
|
|||||||
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
||||||
public string? ImportName { get; set; }
|
public string? ImportName { get; set; }
|
||||||
public int? ExistingCharacterID { get; set; }
|
public int? ExistingCharacterID { get; set; }
|
||||||
|
public int? AliasTargetCharacterID { get; set; }
|
||||||
public string? AliasTargetKey { 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 static class StoryIntelligenceCharacterImportActions
|
||||||
{
|
{
|
||||||
public const string CreateNew = "CreateNew";
|
public const string CreateNew = "CreateNew";
|
||||||
|
|||||||
@ -23,7 +23,13 @@
|
|||||||
<div class="alert alert-success">@message</div>
|
<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">
|
<section class="story-review-chapter-list" aria-label="Character review">
|
||||||
@if (Model.CharacterReview.Candidates.Count == 0)
|
@if (Model.CharacterReview.Candidates.Count == 0)
|
||||||
@ -63,7 +69,7 @@
|
|||||||
<summary>
|
<summary>
|
||||||
<span>
|
<span>
|
||||||
<strong>@candidate.CharacterName</strong>
|
<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>
|
</span>
|
||||||
<em>@candidate.Confidence</em>
|
<em>@candidate.Confidence</em>
|
||||||
</summary>
|
</summary>
|
||||||
@ -134,11 +140,11 @@
|
|||||||
|
|
||||||
<div data-character-alias-panel hidden>
|
<div data-character-alias-panel hidden>
|
||||||
<label class="form-label" for="character-alias-target-@i">Alias target</label>
|
<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>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -187,6 +193,10 @@
|
|||||||
const previous = select.value;
|
const previous = select.value;
|
||||||
for (const option of Array.from(select.options)) {
|
for (const option of Array.from(select.options)) {
|
||||||
if (!option.value) continue;
|
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);
|
const targetCard = allCards.find(item => item.querySelector("[data-character-key]")?.getAttribute("data-character-key") === option.value);
|
||||||
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
|
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user