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 enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules),
("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 post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("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.");
}
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()
{
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=\"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.");
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("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.");
@ -1283,10 +1294,10 @@ static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext()
{
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-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" }
]
};
@ -1297,10 +1308,12 @@ static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext()
var context = JsonSerializer.Deserialize<List<StoryIntelligenceKnownCharacterContext>>(json!, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var maggie = context.Single(item => item.CanonicalName == "Maggie");
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("Mags"), "Alias target should include Mags.");
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.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 == "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 int? ExistingCharacterID { get; init; }
public string? AliasTargetTemporaryCharacterKey { get; init; }
public string? SexHint { get; init; }
}
public sealed class OnboardingManuscriptBuildResult

View File

@ -156,6 +156,7 @@ public sealed class StoryIntelligenceKnownCharacterContext
{
public string CanonicalName { get; init; } = string.Empty;
public IReadOnlyList<string> Aliases { get; init; } = [];
public string SexHint { get; init; } = "Unknown";
public int? ExistingCharacterID { get; init; }
public IReadOnlyList<string> CandidateKeys { 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,
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
Name = candidate.Name,
Category = candidate.Category
Category = candidate.Category,
SexHint = CharacterSexHintService.Suggest(candidate.Name)
})
.ToList()
};

View File

@ -180,7 +180,8 @@ public sealed class OnboardingService(
Name = cleanName,
Category = item.Category,
ExistingCharacterID = matchedExistingId,
AliasTargetTemporaryCharacterKey = aliasTargetKey
AliasTargetTemporaryCharacterKey = aliasTargetKey,
SexHint = CharacterSexHintService.Normalise(item.SexHint, cleanName)
};
})
.ToList();
@ -426,7 +427,8 @@ public sealed class OnboardingService(
AliasTargetName = !string.IsNullOrWhiteSpace(decision?.AliasTargetTemporaryCharacterKey)
&& characterReview.TryGetValue(decision.AliasTargetTemporaryCharacterKey, out var targetDecision)
? targetDecision.Name
: null
: null,
SexHint = CharacterSexHintService.Normalise(decision?.SexHint, reviewName)
};
})
.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)
.DefaultIfEmpty(0.5m)
.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
{
CanonicalName = root.Name,
Aliases = aliases,
SexHint = sexHint,
ExistingCharacterID = root.ExistingCharacterID,
CandidateKeys = allKeys,
MentionCount = mentionCount,

View File

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

View File

@ -276,6 +276,15 @@
}
</select>
</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>
@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].SexHint" value="@candidate.SexHint" />
</div>
}
</aside>