Add reviewable character sex hints

This commit is contained in:
Nick Beckley 2026-08-16 20:23:37 +00:00
parent dbbc46f02b
commit 06c4bf6023
9 changed files with 117 additions and 5 deletions

View File

@ -64,6 +64,7 @@ var tests = new (string Name, Action Test)[]
("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures), ("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules), ("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules),
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific), ("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext), ("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
@ -841,6 +842,14 @@ static void ScanReviewPostSupportsFullBookFormSubmissions()
Assert(((IRequestSizeLimitMetadata?)requestSize)?.MaxRequestBodySize >= 25 * 1024 * 1024, "Scan review request size limit should support full-book previews."); Assert(((IRequestSizeLimitMetadata?)requestSize)?.MaxRequestBodySize >= 25 * 1024 * 1024, "Scan review request size limit should support full-book previews.");
} }
static void CharacterSexHintsAreConservativeAndReviewable()
{
Assert(CharacterSexHintService.Suggest("Cindy") == CharacterSexHintService.Female, "Cindy should default to a female sex hint.");
Assert(CharacterSexHintService.Suggest("Kevin") == CharacterSexHintService.Male, "Kevin should default to a male sex hint.");
Assert(CharacterSexHintService.Suggest("Alex") == CharacterSexHintService.Unknown, "Ambiguous names should not be guessed.");
Assert(CharacterSexHintService.Normalise("Female", "Alex") == CharacterSexHintService.Female, "Reviewed sex hints should override the name heuristic.");
}
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel() static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
{ {
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml")); var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
@ -1153,6 +1162,8 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules()
Assert(onboarding.Contains("<select class=\"form-select\" name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Primary age group must be a dropdown."); Assert(onboarding.Contains("<select class=\"form-select\" name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Primary age group must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-select\" name=\"storyEra\"", StringComparison.Ordinal), "Story era must be a dropdown."); Assert(onboarding.Contains("<select class=\"form-select\" name=\"storyEra\"", StringComparison.Ordinal), "Story era must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-select\" name=\"storyLocation\"", StringComparison.Ordinal), "Story location must be a dropdown."); Assert(onboarding.Contains("<select class=\"form-select\" name=\"storyLocation\"", StringComparison.Ordinal), "Story location must be a dropdown.");
var scanReview = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
Assert(scanReview.Contains(".SexHint", StringComparison.Ordinal) && scanReview.Contains("Non-binary / other", StringComparison.Ordinal), "Character review must expose an editable sex hint dropdown.");
Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction."); Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction.");
Assert(storyMemory.Contains("SkinToneInstruction(skinTone)", StringComparison.Ordinal), "Demand generation must use hard skin-tone wording."); Assert(storyMemory.Contains("SkinToneInstruction(skinTone)", StringComparison.Ordinal), "Demand generation must use hard skin-tone wording.");
Assert(storyMemory.Contains("HairColours.Red", StringComparison.Ordinal) && storyMemory.Contains("SkinTones.Light", StringComparison.Ordinal), "Red-hair demand evidence must force light skin when skin is otherwise unknown."); Assert(storyMemory.Contains("HairColours.Red", StringComparison.Ordinal) && storyMemory.Contains("SkinTones.Light", StringComparison.Ordinal), "Red-hair demand evidence must force light skin when skin is otherwise unknown.");
@ -1283,10 +1294,10 @@ static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext()
{ {
Characters = Characters =
[ [
new() { TemporaryCharacterKey = "c-maggie", Include = true, Name = "Maggie", Category = "ProbableCharacter", ExistingCharacterID = 123 }, new() { TemporaryCharacterKey = "c-maggie", Include = true, Name = "Maggie", Category = "ProbableCharacter", ExistingCharacterID = 123, SexHint = CharacterSexHintService.Female },
new() { TemporaryCharacterKey = "c-margaret", Include = true, Name = "Margaret", Category = "ProbableCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" }, new() { TemporaryCharacterKey = "c-margaret", Include = true, Name = "Margaret", Category = "ProbableCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" },
new() { TemporaryCharacterKey = "c-mags", Include = true, Name = "Mags", Category = "PossibleCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" }, new() { TemporaryCharacterKey = "c-mags", Include = true, Name = "Mags", Category = "PossibleCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" },
new() { TemporaryCharacterKey = "c-kevin", Include = true, Name = "Kevin", Category = "ProbableCharacter" }, new() { TemporaryCharacterKey = "c-kevin", Include = true, Name = "Kevin", Category = "ProbableCharacter", SexHint = CharacterSexHintService.Male },
new() { TemporaryCharacterKey = "c-road", Include = true, Name = "Road", Category = "Excluded" } new() { TemporaryCharacterKey = "c-road", Include = true, Name = "Road", Category = "Excluded" }
] ]
}; };
@ -1297,10 +1308,12 @@ static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext()
var context = JsonSerializer.Deserialize<List<StoryIntelligenceKnownCharacterContext>>(json!, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; var context = JsonSerializer.Deserialize<List<StoryIntelligenceKnownCharacterContext>>(json!, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var maggie = context.Single(item => item.CanonicalName == "Maggie"); var maggie = context.Single(item => item.CanonicalName == "Maggie");
Assert(maggie.ExistingCharacterID == 123, "Existing character ID should stay on the canonical scan character."); Assert(maggie.ExistingCharacterID == 123, "Existing character ID should stay on the canonical scan character.");
Assert(maggie.SexHint == CharacterSexHintService.Female, "Known character context should preserve the reviewed sex hint.");
Assert(maggie.Aliases.Contains("Margaret"), "Alias target should include Margaret."); Assert(maggie.Aliases.Contains("Margaret"), "Alias target should include Margaret.");
Assert(maggie.Aliases.Contains("Mags"), "Alias target should include Mags."); Assert(maggie.Aliases.Contains("Mags"), "Alias target should include Mags.");
Assert(maggie.MentionCount == 13, $"Alias mention counts should accumulate, got {maggie.MentionCount}."); Assert(maggie.MentionCount == 13, $"Alias mention counts should accumulate, got {maggie.MentionCount}.");
Assert(context.Any(item => item.CanonicalName == "Kevin"), "Other included canonical candidates should remain available."); Assert(context.Any(item => item.CanonicalName == "Kevin"), "Other included canonical candidates should remain available.");
Assert(context.Single(item => item.CanonicalName == "Kevin").SexHint == CharacterSexHintService.Male, "Male reviewed sex hints should be preserved.");
Assert(!context.Any(item => item.CanonicalName == "Margaret"), "Alias candidates should not become separate canonical context records."); Assert(!context.Any(item => item.CanonicalName == "Margaret"), "Alias candidates should not become separate canonical context records.");
Assert(!context.Any(item => item.CanonicalName == "Road"), "Excluded scan candidates should not guide Story Intelligence."); Assert(!context.Any(item => item.CanonicalName == "Road"), "Excluded scan candidates should not guide Story Intelligence.");
} }

View File

@ -154,6 +154,7 @@ public sealed class ManuscriptScanCharacterReviewDecision
public string Category { get; init; } = "PossibleCharacter"; public string Category { get; init; } = "PossibleCharacter";
public int? ExistingCharacterID { get; init; } public int? ExistingCharacterID { get; init; }
public string? AliasTargetTemporaryCharacterKey { get; init; } public string? AliasTargetTemporaryCharacterKey { get; init; }
public string? SexHint { get; init; }
} }
public sealed class OnboardingManuscriptBuildResult public sealed class OnboardingManuscriptBuildResult

View File

@ -156,6 +156,7 @@ public sealed class StoryIntelligenceKnownCharacterContext
{ {
public string CanonicalName { get; init; } = string.Empty; public string CanonicalName { get; init; } = string.Empty;
public IReadOnlyList<string> Aliases { get; init; } = []; public IReadOnlyList<string> Aliases { get; init; } = [];
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 int MentionCount { get; init; } public int MentionCount { get; init; }

View File

@ -0,0 +1,73 @@
namespace PlotLine.Services;
public static class CharacterSexHintService
{
public const string Unknown = "Unknown";
public const string Female = "Female";
public const string Male = "Male";
public const string NonBinaryOrOther = "NonBinaryOrOther";
private static readonly HashSet<string> FemaleNames = new(StringComparer.OrdinalIgnoreCase)
{
"abby", "abigail", "alice", "alison", "allison", "amanda", "amelia", "amy", "angela", "anna", "anne",
"beth", "bethany", "carol", "caroline", "catherine", "charlotte", "chloe", "cindy", "claire", "daisy",
"diana", "emily", "emma", "eva", "eve", "grace", "hannah", "helen", "isabel", "isabella", "jane",
"jessica", "julia", "julie", "kate", "katie", "laura", "lily", "lucy", "maggie", "margaret", "mary",
"molly", "nicola", "olivia", "rachel", "rebecca", "rose", "sarah", "sophie", "susan", "victoria",
"zoe"
};
private static readonly HashSet<string> MaleNames = new(StringComparer.OrdinalIgnoreCase)
{
"adam", "alan", "alexander", "andrew", "anthony", "arthur", "ben", "benjamin", "brian", "charles",
"chris", "christopher", "daniel", "david", "edward", "george", "harry", "henry", "ian", "jack",
"james", "john", "jonathan", "joseph", "kevin", "mark", "matthew", "michael", "nick", "nicolas",
"nicholas", "oliver", "paul", "peter", "philip", "richard", "robert", "samuel", "simon", "stephen",
"steven", "thomas", "tom", "william"
};
private static readonly HashSet<string> AmbiguousNames = new(StringComparer.OrdinalIgnoreCase)
{
"alex", "andy", "ashley", "billie", "blair", "bobbie", "casey", "charlie", "drew", "frankie",
"jamie", "jessie", "jo", "jordan", "lee", "leslie", "morgan", "pat", "riley", "robyn", "robin",
"sam", "sandy", "shannon", "taylor", "terry"
};
public static string Suggest(string? name)
{
var token = FirstNameToken(name);
if (string.IsNullOrWhiteSpace(token) || AmbiguousNames.Contains(token))
{
return Unknown;
}
if (FemaleNames.Contains(token))
{
return Female;
}
return MaleNames.Contains(token) ? Male : Unknown;
}
public static string Normalise(string? value, string? fallbackName = null)
=> value?.Trim() switch
{
Female => Female,
Male => Male,
NonBinaryOrOther => NonBinaryOrOther,
Unknown => Unknown,
_ => Suggest(fallbackName)
};
private static string FirstNameToken(string? name)
{
var clean = name?.Trim();
if (string.IsNullOrWhiteSpace(clean))
{
return string.Empty;
}
var first = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty;
return first.Trim('.', ',', ';', ':', '\'', '"').ToLowerInvariant();
}
}

View File

@ -279,7 +279,8 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
TemporaryCharacterKey = candidate.TemporaryCharacterKey, TemporaryCharacterKey = candidate.TemporaryCharacterKey,
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase), Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
Name = candidate.Name, Name = candidate.Name,
Category = candidate.Category Category = candidate.Category,
SexHint = CharacterSexHintService.Suggest(candidate.Name)
}) })
.ToList() .ToList()
}; };

View File

@ -180,7 +180,8 @@ public sealed class OnboardingService(
Name = cleanName, Name = cleanName,
Category = item.Category, Category = item.Category,
ExistingCharacterID = matchedExistingId, ExistingCharacterID = matchedExistingId,
AliasTargetTemporaryCharacterKey = aliasTargetKey AliasTargetTemporaryCharacterKey = aliasTargetKey,
SexHint = CharacterSexHintService.Normalise(item.SexHint, cleanName)
}; };
}) })
.ToList(); .ToList();
@ -426,7 +427,8 @@ public sealed class OnboardingService(
AliasTargetName = !string.IsNullOrWhiteSpace(decision?.AliasTargetTemporaryCharacterKey) AliasTargetName = !string.IsNullOrWhiteSpace(decision?.AliasTargetTemporaryCharacterKey)
&& characterReview.TryGetValue(decision.AliasTargetTemporaryCharacterKey, out var targetDecision) && characterReview.TryGetValue(decision.AliasTargetTemporaryCharacterKey, out var targetDecision)
? targetDecision.Name ? targetDecision.Name
: null : null,
SexHint = CharacterSexHintService.Normalise(decision?.SexHint, reviewName)
}; };
}) })
.ToList(); .ToList();

View File

@ -296,11 +296,20 @@ public sealed class OnboardingStoryIntelligenceService(
.Select(key => candidates.TryGetValue(key, out var candidate) ? Math.Clamp(candidate.QualityScore / 100m, 0.1m, 1m) : 0.5m) .Select(key => candidates.TryGetValue(key, out var candidate) ? Math.Clamp(candidate.QualityScore / 100m, 0.1m, 1m) : 0.5m)
.DefaultIfEmpty(0.5m) .DefaultIfEmpty(0.5m)
.Max(); .Max();
var sexHint = CharacterSexHintService.Normalise(root.SexHint, root.Name);
if (string.Equals(sexHint, CharacterSexHintService.Unknown, StringComparison.Ordinal))
{
sexHint = aliasDecisions
.Select(alias => CharacterSexHintService.Normalise(alias.SexHint, alias.Name))
.FirstOrDefault(value => !string.Equals(value, CharacterSexHintService.Unknown, StringComparison.Ordinal))
?? CharacterSexHintService.Unknown;
}
context.Add(new StoryIntelligenceKnownCharacterContext context.Add(new StoryIntelligenceKnownCharacterContext
{ {
CanonicalName = root.Name, CanonicalName = root.Name,
Aliases = aliases, Aliases = aliases,
SexHint = sexHint,
ExistingCharacterID = root.ExistingCharacterID, ExistingCharacterID = root.ExistingCharacterID,
CandidateKeys = allKeys, CandidateKeys = allKeys,
MentionCount = mentionCount, MentionCount = mentionCount,

View File

@ -673,6 +673,7 @@ public sealed class ManuscriptScanReviewCharacterViewModel
public bool IsExistingCharacterMatch => ExistingCharacterID.HasValue; public bool IsExistingCharacterMatch => ExistingCharacterID.HasValue;
public string? AliasTargetTemporaryCharacterKey { get; init; } public string? AliasTargetTemporaryCharacterKey { get; init; }
public string? AliasTargetName { get; init; } public string? AliasTargetName { get; init; }
public string SexHint { get; init; } = "Unknown";
public bool IsAlias => !string.IsNullOrWhiteSpace(AliasTargetTemporaryCharacterKey); public bool IsAlias => !string.IsNullOrWhiteSpace(AliasTargetTemporaryCharacterKey);
} }
@ -716,4 +717,5 @@ public sealed class ManuscriptScanCharacterReviewFormItem
public string Category { get; set; } = "PossibleCharacter"; public string Category { get; set; } = "PossibleCharacter";
public int? ExistingCharacterID { get; set; } public int? ExistingCharacterID { get; set; }
public string? AliasTargetTemporaryCharacterKey { get; set; } public string? AliasTargetTemporaryCharacterKey { get; set; }
public string? SexHint { get; set; }
} }

View File

@ -276,6 +276,15 @@
} }
</select> </select>
</label> </label>
<label class="onboarding-review-field">
Sex
<select class="form-select" name="Characters[@currentCharacterIndex].SexHint">
<option value="Unknown" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Unknown, StringComparison.Ordinal))">Unknown</option>
<option value="Female" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Female, StringComparison.Ordinal))">Female</option>
<option value="Male" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Male, StringComparison.Ordinal))">Male</option>
<option value="NonBinaryOrOther" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.NonBinaryOrOther, StringComparison.Ordinal))">Non-binary / other</option>
</select>
</label>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span> <span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName)) @if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
{ {
@ -311,6 +320,7 @@
{ {
<input type="hidden" name="Characters[@hiddenCharacterIndex].AliasTargetTemporaryCharacterKey" value="@candidate.AliasTargetTemporaryCharacterKey" /> <input type="hidden" name="Characters[@hiddenCharacterIndex].AliasTargetTemporaryCharacterKey" value="@candidate.AliasTargetTemporaryCharacterKey" />
} }
<input type="hidden" name="Characters[@hiddenCharacterIndex].SexHint" value="@candidate.SexHint" />
</div> </div>
} }
</aside> </aside>