Add reviewable character sex hints
This commit is contained in:
parent
dbbc46f02b
commit
06c4bf6023
@ -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.");
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
73
PlotLine/Services/CharacterSexHintService.cs
Normal file
73
PlotLine/Services/CharacterSexHintService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
};
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user