Fix character review defaults

This commit is contained in:
Nick Beckley 2026-08-24 19:12:51 +00:00
parent 2bb5decf63
commit 1dfc620e00
5 changed files with 219 additions and 6 deletions

View File

@ -74,6 +74,12 @@ var tests = new (string Name, Action Test)[]
("Name Intelligence reference data is copied on publish", NameIntelligenceReferenceDataIsCopiedOnPublish),
("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures),
("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired),
("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 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),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions),
("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation),
@ -1926,6 +1932,67 @@ static void NameIntelligenceReviewWarningIsWired()
Assert(view.Contains("Name warning", StringComparison.Ordinal), "Character review view should render the name warning.");
}
static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches()
{
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting,
"A proposed existing character match should default to Link Existing.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Mum", 42) == StoryIntelligenceCharacterImportActions.LinkExisting,
"An explicit/proposed existing character match should beat the generic reference heuristic.");
}
static void StoryIntelligenceCharacterDefaultsIgnoreGenericReferences()
{
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Mum", null) == StoryIntelligenceCharacterImportActions.Ignore,
"Family-role references should default to Ignore.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("One of his mates", null) == StoryIntelligenceCharacterImportActions.Ignore,
"Contextual indefinite references should default to Ignore.");
}
static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities()
{
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Creepy guy", null) == StoryIntelligenceCharacterImportActions.CreateNew,
"Specific descriptive identities should remain create-new candidates.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Guy who gropes narrator", null) == StoryIntelligenceCharacterImportActions.CreateNew,
"Specific descriptive identities should not be treated as generic group references.");
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Zoe", null) == StoryIntelligenceCharacterImportActions.CreateNew,
"Named new characters without an existing match should default to Create New.");
}
static void StoryIntelligenceCharacterReviewPostsSelectedExistingMatch()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
Assert(model.Contains("DefaultAction", StringComparison.Ordinal), "Character review candidates should carry a server-side default action.");
Assert(model.Contains("SelectedExistingCharacterID", StringComparison.Ordinal), "Character review candidates should carry the selected existing match.");
Assert(view.Contains("name=\"Characters[@i].ExistingCharacterID\"", StringComparison.Ordinal), "Review form should post the selected existing character.");
Assert(view.Contains("data-character-existing-panel", StringComparison.Ordinal), "Review form should render an existing-character dropdown.");
Assert(service.Contains("existingIndex.Find(choice.ExistingCharacterID)", StringComparison.Ordinal), "Import should use the posted existing-character selection.");
}
static void StoryIntelligenceCharacterDefaultChangesPreservePersistedResults()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
Assert(service.Contains("runs.ListSceneResultsAsync", StringComparison.Ordinal), "Review should continue to derive candidates from saved scene results.");
Assert(service.Contains("TryReadScene(sceneResult)", StringComparison.Ordinal), "Review should continue to read persisted parsed scene JSON.");
Assert(!service.Contains("SaveSceneResultAsync", StringComparison.Ordinal), "Character review defaults must not overwrite saved Story Intelligence scene results.");
Assert(!service.Contains("QueueAdminTextAsync", StringComparison.Ordinal), "Character review defaults must not queue new Story Intelligence work.");
}
static void StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
Assert(service.Contains("parsed.CharacterAppearance", StringComparison.Ordinal), "Character review should inspect structured character appearance data.");
Assert(service.Contains("AddCharacterAppearanceAliases", StringComparison.Ordinal), "Structured appearance aliases should feed character candidates.");
Assert(service.Contains("Aliases = appearance.Aliases", StringComparison.Ordinal), "Structured appearance aliases should be reused without another AI call.");
}
static int CountCsvRows(string path)
=> File.ReadLines(path).Skip(1).Count(line => !string.IsNullOrWhiteSpace(line));

View File

@ -73,6 +73,14 @@ public sealed class StoryIntelligenceCharacterImportService(
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var nameWarnings = await BuildNameIntelligenceWarningsAsync(visibleCandidates, existingIndex);
var existingOptions = existingIndex.Characters
.Select(character => new StoryIntelligenceCharacterExistingOptionViewModel
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName
})
.OrderBy(character => character.CharacterName)
.ToList();
return new StoryIntelligenceCharacterReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
@ -81,6 +89,7 @@ public sealed class StoryIntelligenceCharacterImportService(
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
{
DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(candidate.DisplayName, candidate.ExistingCharacterID),
Key = candidate.Key,
CharacterName = candidate.DisplayName,
ImportName = candidate.DisplayName,
@ -90,6 +99,8 @@ public sealed class StoryIntelligenceCharacterImportService(
ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExistingCharacterID = candidate.ExistingCharacterID,
ExistingCharacterName = candidate.ExistingCharacterName,
SelectedExistingCharacterID = candidate.ExistingCharacterID,
ExistingCharacterOptions = existingOptions,
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(candidate.Key)
}).ToList()
};
@ -210,7 +221,8 @@ public sealed class StoryIntelligenceCharacterImportService(
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = linkExisting
? candidate.ExistingCharacterID
? existingIndex.Find(choice.ExistingCharacterID)?.CharacterID
?? candidate.ExistingCharacterID
?? existingIndex.Find(importName)?.CharacterID
?? existingIndex.Find(candidate.DisplayName)?.CharacterID
: null;
@ -498,6 +510,11 @@ public sealed class StoryIntelligenceCharacterImportService(
AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances);
}
foreach (var appearance in parsed.CharacterAppearance ?? [])
{
AddCharacterAppearanceAliases(groups, existingIndex, importedScene, parsed, appearance, existingAppearances);
}
var povName = CleanCharacterName(parsed.PointOfView?.CharacterName);
if (IsCharacterNameCandidate(povName))
{
@ -537,6 +554,31 @@ public sealed class StoryIntelligenceCharacterImportService(
}
}
private static void AddCharacterAppearanceAliases(
Dictionary<string, CharacterCandidate> groups,
CharacterIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
SceneIntelligenceCharacterAppearance appearance,
IReadOnlyList<SceneCharacter> existingAppearances)
{
var canonicalName = CleanCharacterName(appearance.CanonicalName);
if (!IsCharacterNameCandidate(canonicalName))
{
return;
}
AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter
{
Name = canonicalName,
RoleInScene = appearance.RoleOrOccupation,
MentionedOnly = false,
Aliases = appearance.Aliases,
Confidence = appearance.Confidence,
Notes = appearance.Evidence
}, existingAppearances);
}
private static void AddObservedName(
Dictionary<string, CharacterCandidate> groups,
CharacterIndex existingIndex,
@ -570,6 +612,7 @@ public sealed class StoryIntelligenceCharacterImportService(
var index = new CharacterIndex();
foreach (var character in await characters.ListCharactersAsync(projectId))
{
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);
@ -943,8 +986,21 @@ public sealed class StoryIntelligenceCharacterImportService(
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<int, CharacterMatch> byId = new();
private readonly List<CharacterIndexRow> rows = [];
private readonly List<CharacterIndexCharacter> characters = [];
public IReadOnlyList<CharacterIndexRow> Rows => rows;
public IReadOnlyList<CharacterIndexCharacter> Characters => characters;
public void AddCharacter(int characterId, string characterName)
{
var match = new CharacterMatch(characterId, characterName);
byId.TryAdd(characterId, match);
if (!characters.Any(character => character.CharacterID == characterId))
{
characters.Add(new CharacterIndexCharacter(characterId, characterName));
}
}
public void Add(string? name, int characterId, string characterName)
{
@ -958,10 +1014,14 @@ public sealed class StoryIntelligenceCharacterImportService(
public CharacterMatch? Find(string name)
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
public CharacterMatch? Find(int? characterId)
=> characterId.HasValue && byId.TryGetValue(characterId.Value, out var match) ? match : null;
}
private sealed record CharacterMatch(int CharacterID, string CharacterName);
private sealed record CharacterIndexRow(string MatchName, string CharacterName);
private sealed record CharacterIndexCharacter(int CharacterID, string CharacterName);
private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName)
{

View File

@ -0,0 +1,52 @@
using System.Text.RegularExpressions;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public static class StoryIntelligenceCharacterReviewDefaults
{
private static readonly HashSet<string> GenericFamilyReferences = CreateSet(
"mum", "mom", "mam", "mother", "dad", "father", "brother", "sister", "grandmother", "grandfather",
"nan", "nanny", "grandma", "grandad", "granddad", "aunt", "auntie", "uncle", "cousin");
private static readonly Regex ContextualIndefiniteReference = new(
@"^(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)
{
if (proposedExistingCharacterId.HasValue)
{
return StoryIntelligenceCharacterImportActions.LinkExisting;
}
if (IsGenericContextualReference(candidateName))
{
return StoryIntelligenceCharacterImportActions.Ignore;
}
return StoryIntelligenceCharacterImportActions.CreateNew;
}
public static bool IsGenericContextualReference(string? candidateName)
{
var normalised = Normalise(candidateName);
if (string.IsNullOrWhiteSpace(normalised))
{
return false;
}
return GenericFamilyReferences.Contains(normalised)
|| ContextualIndefiniteReference.IsMatch(normalised);
}
private static string Normalise(string? value)
{
var cleaned = Regex.Replace(value ?? string.Empty, @"[^\p{L}\p{N}' ]+", " ");
cleaned = cleaned.Replace("’", "'", StringComparison.Ordinal);
return string.Join(' ', cleaned.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant();
}
private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
}

View File

@ -209,6 +209,7 @@ public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
public string Key { get; init; } = string.Empty;
public string CharacterName { get; init; } = string.Empty;
public string ImportName { get; init; } = string.Empty;
public string DefaultAction { get; init; } = StoryIntelligenceCharacterImportActions.CreateNew;
public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown";
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
@ -216,10 +217,19 @@ public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
public string? NameIntelligenceWarning { get; init; }
public int? ExistingCharacterID { get; init; }
public string? ExistingCharacterName { get; init; }
public int? SelectedExistingCharacterID { get; init; }
public IReadOnlyList<StoryIntelligenceCharacterExistingOptionViewModel> ExistingCharacterOptions { get; init; } = [];
public bool IsExistingMatch => ExistingCharacterID.HasValue;
public bool CanLinkExisting => ExistingCharacterOptions.Count > 0;
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
}
public sealed class StoryIntelligenceCharacterExistingOptionViewModel
{
public int CharacterID { get; init; }
public string CharacterName { get; init; } = string.Empty;
}
public sealed class StoryIntelligenceCharacterImportForm
{
public Guid BatchID { get; set; }
@ -231,6 +241,7 @@ public sealed class StoryIntelligenceCharacterImportChoiceForm
public string Key { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew;
public string? ImportName { get; set; }
public int? ExistingCharacterID { get; set; }
public string? AliasTargetKey { get; set; }
}

View File

@ -92,23 +92,41 @@
<fieldset class="story-character-actions">
<legend>Decision</legend>
<label>
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.CreateNew" checked data-character-action />
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.CreateNew" @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.CreateNew) ? "checked" : string.Empty) data-character-action />
Create new character
</label>
<label>
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.LinkExisting" data-character-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
<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" data-character-action />
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Alias" @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.Alias) ? "checked" : string.Empty) data-character-action />
Alias of another character
</label>
<label>
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" data-character-action />
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.Ignore) ? "checked" : string.Empty) data-character-action />
Ignore
</label>
</fieldset>
<div data-character-existing-panel hidden>
<label class="form-label" for="character-existing-@i">Existing character</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)
{
if (candidate.SelectedExistingCharacterID == existing.CharacterID)
{
<option value="@existing.CharacterID" selected>@existing.CharacterName</option>
}
else
{
<option value="@existing.CharacterID">@existing.CharacterName</option>
}
}
</select>
</div>
<div data-character-import-name-panel>
<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" />
@ -183,7 +201,9 @@
const action = actionFor(card);
const namePanel = card.querySelector("[data-character-import-name-panel]");
const aliasPanel = card.querySelector("[data-character-alias-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.Alias";
const existingPanel = card.querySelector("[data-character-existing-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.Alias" || action === "@StoryIntelligenceCharacterImportActions.LinkExisting";
if (existingPanel) existingPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.LinkExisting";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.Alias";
updateAliasTargets();
};
@ -212,6 +232,9 @@
@functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
private static bool IsDefault(StoryIntelligenceCharacterReviewCandidateViewModel candidate, string action)
=> string.Equals(candidate.DefaultAction, action, StringComparison.OrdinalIgnoreCase);
private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>");
}