Compare commits
2 Commits
onboarding
...
visualisat
| Author | SHA1 | Date | |
|---|---|---|---|
| f6b4263f31 | |||
| c96c20d18b |
@ -65,6 +65,7 @@ var tests = new (string Name, Action Test)[]
|
||||
("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),
|
||||
("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates),
|
||||
("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),
|
||||
@ -535,7 +536,9 @@ static void IllustrationMatchingRejectsDuplicateNamedPortraitAllocation()
|
||||
static void IllustrationSemanticTypesPreventObviousMismatches()
|
||||
{
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom at Beth's house") == "Bathroom", "Bathroom should classify as Bathroom.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("ladies toilets in the pub") == "PublicToilet", "Public toilets must not classify as domestic bathrooms.");
|
||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "Office"), "Bathroom must reject office imagery.");
|
||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("PublicToilet", "Bathroom"), "Pub/bar toilets must not use domestic bathroom imagery.");
|
||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "HouseExterior"), "Kitchen must reject house exterior imagery.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "FlatInterior"), "Kitchen may use generic interior fallback.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ambulance") == "Ambulance", "Ambulance should classify as Ambulance.");
|
||||
@ -846,10 +849,37 @@ 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("Colin") == CharacterSexHintService.Male, "Colin should default to a male sex hint.");
|
||||
Assert(CharacterSexHintService.Suggest("Debbie") == CharacterSexHintService.Female, "Debbie should default to a female sex hint.");
|
||||
Assert(CharacterSexHintService.Suggest("Chrissy") == CharacterSexHintService.Female, "Chrissy should default to a female sex hint.");
|
||||
Assert(CharacterSexHintService.Suggest("Clara") == CharacterSexHintService.Female, "Clara should default to a female sex hint.");
|
||||
Assert(CharacterSexHintService.Suggest("Greg") == CharacterSexHintService.Male, "Greg should default to a male sex hint.");
|
||||
Assert(CharacterSexHintService.Suggest("Judy") == CharacterSexHintService.Female, "Judy should default to a female sex hint.");
|
||||
Assert(CharacterSexHintService.Suggest("Liam") == CharacterSexHintService.Male, "Liam should default to a male sex hint.");
|
||||
Assert(CharacterSexHintService.Suggest("Phil") == CharacterSexHintService.Male, "Phil should default to a male sex hint.");
|
||||
Assert(CharacterSexHintService.Suggest("Mrs Palmer") == CharacterSexHintService.Female, "Feminine titles should provide a female sex hint.");
|
||||
Assert(CharacterSexHintService.Suggest("Mr Palmer") == CharacterSexHintService.Male, "Masculine titles should provide a male sex hint.");
|
||||
Assert(CharacterSexHintService.Suggest("Alex") == CharacterSexHintService.Unknown, "Ambiguous names should not be guessed.");
|
||||
Assert(CharacterSexHintService.Suggest("Becks") == CharacterSexHintService.Unknown, "Unclear nicknames should remain reviewable.");
|
||||
Assert(CharacterSexHintService.Normalise("Female", "Alex") == CharacterSexHintService.Female, "Reviewed sex hints should override the name heuristic.");
|
||||
}
|
||||
|
||||
static void CharacterDiscoveryDoesNotTruncateScanReviewCandidates()
|
||||
{
|
||||
var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
Assert(method is not null, "Character discovery method could not be found.");
|
||||
|
||||
var builder = new StringBuilder();
|
||||
for (var index = 0; index < 60; index++)
|
||||
{
|
||||
var name = $"Person{(char)('a' + index / 26)}{(char)('a' + index % 26)}";
|
||||
builder.Append(name).Append(" said hello. ").Append(name).Append(" smiled. ");
|
||||
}
|
||||
|
||||
var candidates = (IReadOnlyList<WordCompanionCharacterCandidateDto>)method!.Invoke(null, [builder.ToString(), false])!;
|
||||
Assert(candidates.Count >= 60, $"Character discovery should not truncate the scan review to 50 candidates; got {candidates.Count}.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
|
||||
{
|
||||
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
||||
@ -951,7 +981,7 @@ static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden()
|
||||
Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation.");
|
||||
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
|
||||
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay.");
|
||||
Assert(script.Contains("relationships.slice(0, 1)", StringComparison.Ordinal), "Relationship changes should be capped to the most relevant visible card.");
|
||||
Assert(script.Contains("relationships.slice(0, 3)", StringComparison.Ordinal), "Relationship changes should show several visible cards without overflowing the panel.");
|
||||
Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not hide readable content behind '+N more' overflow controls.");
|
||||
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
|
||||
Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll.");
|
||||
@ -1125,10 +1155,11 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules()
|
||||
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should be Lane.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should be Road.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Street", "Road"), "Side streets should be able to reuse road artwork while demand-specific street art is pending.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "Street"), "Named roads should be able to reuse street artwork when it is the closest generated road-family image.");
|
||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Street", "Road"), "Named streets should wait for street-specific imagery rather than reuse generic road art.");
|
||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "Street"), "Named roads should wait for road-specific imagery rather than reuse street art.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("car interior") == "VehicleInterior", "Car interior should be VehicleInterior.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom") == "Bathroom", "Bathroom should be Bathroom.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("ladies toilets in the Rose and Crown pub") == "PublicToilet", "Pub toilets should be PublicToilet.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("waiting room") == "WaitingRoom", "Waiting room should be WaitingRoom.");
|
||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "VehicleInterior must not use building art.");
|
||||
|
||||
@ -1239,6 +1270,8 @@ static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
|
||||
Assert(script.Contains("appearance pending", StringComparison.Ordinal), "Character evidence cards should expose unresolved appearance state.");
|
||||
Assert(!css.Contains(".story-exp-insight-list {\n display: grid;\n gap: 9px;\n min-height: 0;\n overflow: auto", StringComparison.Ordinal), "Nested insight lists should not create native scrollbars.");
|
||||
Assert(progressViewModel.Contains("public int? ImportSessionID", StringComparison.Ordinal), "Import progress page should carry the import session for replay links.");
|
||||
Assert(script.Contains(".filter((relationship) => relationship.weight >= 54)", StringComparison.Ordinal), "Relationship links should render moderate-confidence relationship evidence.");
|
||||
Assert(script.Contains(".slice(0, 5)", StringComparison.Ordinal), "Relationship drawing should not be limited to two lines.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
|
||||
@ -1262,6 +1295,7 @@ static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Driving Test Centre waiting room") == "DrivingTestCentre", "Driving test centre should have its own location family.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road kitchen") == "Kitchen", "Functional place context should beat a nearby road name.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road car park") == "CarPark", "Car park context should beat a nearby road name.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("ladies toilets in a bar") == "PublicToilet", "Bar toilets should be PublicToilet.");
|
||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "Vehicle interiors must not resolve to houses or buildings.");
|
||||
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("passport") == "Passport", "Passport should not be Generic Object.");
|
||||
@ -1273,6 +1307,7 @@ static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
|
||||
|
||||
Assert(snapshot.Contains("type is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Road-family scene locations need a generic road fallback illustration.");
|
||||
Assert(snapshot.Contains("\"loc-vehicle-interior\" => $\"{FallbackRoot}/location-vehicle-interior.svg\"", StringComparison.Ordinal), "Vehicle interiors need a non-building fallback illustration.");
|
||||
Assert(snapshot.Contains("\"loc-generic-public-toilet\" => string.Empty", StringComparison.Ordinal), "Public toilets must not reuse domestic bathroom fallback art.");
|
||||
Assert(snapshot.Contains("\"asset-document\" => $\"{FallbackRoot}/asset-document.svg\"", StringComparison.Ordinal), "Documents need a recognisable document fallback.");
|
||||
Assert(snapshot.Contains("\"asset-traffic-sign\" => $\"{FallbackRoot}/asset-traffic-sign.svg\"", StringComparison.Ordinal), "Traffic signs need a recognisable fallback.");
|
||||
}
|
||||
|
||||
@ -199,6 +199,7 @@ public class Program
|
||||
builder.Services.AddHostedService<WordCompanionPresenceMonitor>();
|
||||
builder.Services.AddScoped<IOnboardingService, OnboardingService>();
|
||||
builder.Services.AddScoped<IOnboardingStoryIntelligenceService, OnboardingStoryIntelligenceService>();
|
||||
builder.Services.AddScoped<ICharacterSexHintAiService, CharacterSexHintAiService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceService, StoryIntelligenceService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProvider, StubStoryIntelligenceProvider>();
|
||||
builder.Services.AddSingleton<IStoryPromptRepository, StoryPromptRepository>();
|
||||
|
||||
152
PlotLine/Services/CharacterSexHintAiService.cs
Normal file
152
PlotLine/Services/CharacterSexHintAiService.cs
Normal file
@ -0,0 +1,152 @@
|
||||
using System.Text.Json;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface ICharacterSexHintAiService
|
||||
{
|
||||
Task<IReadOnlyDictionary<string, string>> SuggestAsync(
|
||||
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class CharacterSexHintAiService(
|
||||
IStoryIntelligenceClient client,
|
||||
ILogger<CharacterSexHintAiService> logger) : ICharacterSexHintAiService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, string>> SuggestAsync(
|
||||
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var promptCandidates = candidates
|
||||
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.TemporaryCharacterKey) && !string.IsNullOrWhiteSpace(candidate.Name))
|
||||
.Select(candidate => new CharacterSexHintPromptCandidate(candidate.TemporaryCharacterKey, candidate.Name.Trim()))
|
||||
.DistinctBy(candidate => candidate.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (promptCandidates.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await client.ExecutePromptAsync(
|
||||
BuildPrompt(promptCandidates),
|
||||
"Character-Sex-Hint-V1",
|
||||
cancellationToken,
|
||||
maxOutputTokensOverride: Math.Max(600, promptCandidates.Count * 20));
|
||||
var outputText = ExtractOutputText(result.RawResponseText);
|
||||
return ParseSuggestions(outputText, promptCandidates);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "AI character sex hinting failed; falling back to deterministic title/name hints.");
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildPrompt(IReadOnlyList<CharacterSexHintPromptCandidate> candidates)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(candidates.Select(candidate => new
|
||||
{
|
||||
key = candidate.Key,
|
||||
name = candidate.Name
|
||||
}), JsonOptions);
|
||||
|
||||
return $$"""
|
||||
You are helping prepare a fiction manuscript import review.
|
||||
|
||||
For each candidate character name, infer the most probable sex presentation from the name/title alone.
|
||||
Return only valid JSON in this exact shape:
|
||||
{
|
||||
"characters": [
|
||||
{ "key": "same key", "sexHint": "Female|Male|NonBinaryOrOther|Unknown" }
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Use Female or Male for common names and explicit titles such as Miss, Mrs, Mr, Sir, Lady.
|
||||
- Use Unknown when the name is genuinely ambiguous, invented, a surname-only reference, or not enough information is present.
|
||||
- Use NonBinaryOrOther only when the supplied name/title explicitly indicates that.
|
||||
- Do not add explanations.
|
||||
|
||||
Candidates:
|
||||
{{payload}}
|
||||
""";
|
||||
}
|
||||
|
||||
private static string ExtractOutputText(string rawResponseText)
|
||||
{
|
||||
var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(rawResponseText, JsonOptions)
|
||||
?? throw new JsonException("OpenAI response envelope was empty.");
|
||||
return string.Concat(response.Output?
|
||||
.SelectMany(item => item.Content ?? [])
|
||||
.Where(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(content => content.Text)
|
||||
.Where(text => !string.IsNullOrWhiteSpace(text))
|
||||
?? []);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ParseSuggestions(
|
||||
string outputText,
|
||||
IReadOnlyList<CharacterSexHintPromptCandidate> candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outputText))
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var candidateKeys = candidates.Select(candidate => candidate.Key).ToHashSet(StringComparer.Ordinal);
|
||||
using var document = JsonDocument.Parse(StripMarkdownFence(outputText));
|
||||
if (!document.RootElement.TryGetProperty("characters", out var characters)
|
||||
|| characters.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var suggestions = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var item in characters.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("key", out var keyProperty)
|
||||
|| !item.TryGetProperty("sexHint", out var sexHintProperty))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = keyProperty.GetString();
|
||||
if (string.IsNullOrWhiteSpace(key) || !candidateKeys.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hint = CharacterSexHintService.Normalise(sexHintProperty.GetString());
|
||||
suggestions[key] = hint;
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
private static string StripMarkdownFence(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
if (!trimmed.StartsWith("```", StringComparison.Ordinal))
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
var firstLineEnd = trimmed.IndexOf('\n');
|
||||
if (firstLineEnd < 0)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
var withoutOpeningFence = trimmed[(firstLineEnd + 1)..].Trim();
|
||||
return withoutOpeningFence.EndsWith("```", StringComparison.Ordinal)
|
||||
? withoutOpeningFence[..^3].Trim()
|
||||
: withoutOpeningFence;
|
||||
}
|
||||
|
||||
private sealed record CharacterSexHintPromptCandidate(string Key, string Name);
|
||||
}
|
||||
@ -9,21 +9,30 @@ public static class CharacterSexHintService
|
||||
|
||||
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"
|
||||
"abby", "abigail", "ada", "adele", "alice", "alison", "allison", "amanda", "amelia", "amy",
|
||||
"angela", "anna", "anne", "annie", "barbara", "becky", "belinda", "beth", "bethany", "brenda",
|
||||
"carol", "caroline", "catherine", "charlotte", "chloe", "chrissy", "christine", "cindy", "claire", "clara",
|
||||
"daisy", "debbie", "debby", "deborah", "diana", "diane", "donna", "dorothy", "elaine", "elizabeth",
|
||||
"ella", "ellie", "emily", "emma", "eva", "eve", "fiona", "gemma", "georgina", "grace", "hannah",
|
||||
"harriet", "heather", "helen", "isabel", "isabella", "jacqueline", "jane", "janet", "janice",
|
||||
"jean", "jennifer", "jenny", "jessica", "joanne", "judy", "julia", "julie", "karen", "kate", "katie",
|
||||
"katherine", "kathryn", "kelly", "laura", "lesley", "linda", "lisa", "lily", "louise", "lucy",
|
||||
"maggie", "margaret", "maria", "marie", "marion", "mary", "michelle", "molly", "nicola", "olivia",
|
||||
"pamela", "patricia", "paula", "rachel", "rebecca", "rose", "sally", "samantha", "sandra", "sarah",
|
||||
"shirley", "sophie", "stephanie", "susan", "suzanne", "tracey", "tracy", "valerie", "victoria",
|
||||
"wendy", "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"
|
||||
"adam", "alan", "albert", "alexander", "andrew", "anthony", "arthur", "barry", "ben", "benjamin",
|
||||
"bernard", "brian", "bruce", "charles", "chris", "christopher", "colin", "craig", "daniel", "darren",
|
||||
"david", "dennis", "douglas", "edward", "eric", "frank", "gary", "geoff", "geoffrey", "george",
|
||||
"graham", "grant", "greg", "harry", "henry", "howard", "ian", "jack", "james", "john", "jonathan", "joseph",
|
||||
"keith", "kenneth", "kevin", "liam", "martin", "mark", "matthew", "michael", "neil", "nick", "nicolas",
|
||||
"nicholas", "nigel", "oliver", "paul", "peter", "phil", "philip", "raymond", "richard", "robert", "roger",
|
||||
"ronald", "samuel", "simon", "stephen", "steven", "stuart", "terence", "thomas", "tim", "timothy",
|
||||
"tom", "tony", "william"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AmbiguousNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
@ -33,9 +42,26 @@ public static class CharacterSexHintService
|
||||
"sam", "sandy", "shannon", "taylor", "terry"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> TitleHints = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["mr"] = Male,
|
||||
["mister"] = Male,
|
||||
["sir"] = Male,
|
||||
["mrs"] = Female,
|
||||
["miss"] = Female,
|
||||
["ms"] = Female,
|
||||
["madam"] = Female,
|
||||
["lady"] = Female
|
||||
};
|
||||
|
||||
public static string Suggest(string? name)
|
||||
{
|
||||
var token = FirstNameToken(name);
|
||||
var token = FirstNameToken(name, out var titleHint);
|
||||
if (!string.IsNullOrWhiteSpace(titleHint))
|
||||
{
|
||||
return titleHint;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token) || AmbiguousNames.Contains(token))
|
||||
{
|
||||
return Unknown;
|
||||
@ -59,15 +85,35 @@ public static class CharacterSexHintService
|
||||
_ => Suggest(fallbackName)
|
||||
};
|
||||
|
||||
private static string FirstNameToken(string? name)
|
||||
private static string FirstNameToken(string? name, out string? titleHint)
|
||||
{
|
||||
titleHint = null;
|
||||
var clean = name?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(clean))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var first = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty;
|
||||
return first.Trim('.', ',', ';', ':', '\'', '"').ToLowerInvariant();
|
||||
var tokens = clean
|
||||
.Split([' ', '-', '_'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(CleanToken)
|
||||
.Where(token => !string.IsNullOrWhiteSpace(token))
|
||||
.ToList();
|
||||
if (tokens.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (TitleHints.TryGetValue(tokens[0], out titleHint))
|
||||
{
|
||||
return tokens.Skip(1).FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
|
||||
return tokens[0];
|
||||
}
|
||||
|
||||
private static string CleanToken(string token)
|
||||
{
|
||||
return token.Trim('.', ',', ';', ':', '\'', '"', '(', ')', '[', ']').ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,9 @@ public interface IManuscriptScanPreviewStore
|
||||
Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result);
|
||||
}
|
||||
|
||||
public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
||||
public sealed class ManuscriptScanPreviewStore(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<ManuscriptScanPreviewStore> logger) : IManuscriptScanPreviewStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new();
|
||||
private readonly ConcurrentDictionary<Guid, ManuscriptScanPreview> previews = new();
|
||||
@ -148,15 +150,21 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
||||
public Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId)
|
||||
=> Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null);
|
||||
|
||||
public Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId)
|
||||
public async Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId)
|
||||
{
|
||||
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
|
||||
{
|
||||
return Task.FromResult<ManuscriptScanReviewDecision?>(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
var review = reviews.GetOrAdd(previewId, _ => BuildDefaultReview(preview));
|
||||
return Task.FromResult<ManuscriptScanReviewDecision?>(review);
|
||||
if (reviews.TryGetValue(previewId, out var review))
|
||||
{
|
||||
return review;
|
||||
}
|
||||
|
||||
review = await BuildDefaultReviewAsync(preview);
|
||||
var stored = reviews.GetOrAdd(previewId, review);
|
||||
return stored;
|
||||
}
|
||||
|
||||
public Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review)
|
||||
@ -246,8 +254,10 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? fallback : cleaned;
|
||||
}
|
||||
|
||||
private static ManuscriptScanReviewDecision BuildDefaultReview(ManuscriptScanPreview preview)
|
||||
=> new()
|
||||
private async Task<ManuscriptScanReviewDecision> BuildDefaultReviewAsync(ManuscriptScanPreview preview)
|
||||
{
|
||||
var aiHints = await GetAiSexHintsAsync(preview.CharacterCandidates);
|
||||
return new ManuscriptScanReviewDecision
|
||||
{
|
||||
PreviewID = preview.PreviewID,
|
||||
Status = ManuscriptScanReviewStatuses.ScanComplete,
|
||||
@ -280,10 +290,34 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
||||
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
|
||||
Name = candidate.Name,
|
||||
Category = candidate.Category,
|
||||
SexHint = CharacterSexHintService.Suggest(candidate.Name)
|
||||
SexHint = aiHints.TryGetValue(candidate.TemporaryCharacterKey, out var aiHint)
|
||||
? aiHint
|
||||
: CharacterSexHintService.Suggest(candidate.Name)
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<string, string>> GetAiSexHintsAsync(
|
||||
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates)
|
||||
{
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var hintService = scope.ServiceProvider.GetRequiredService<ICharacterSexHintAiService>();
|
||||
return await hintService.SuggestAsync(candidates, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not create AI character sex hints for manuscript scan review; using deterministic fallback hints.");
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ScanSession
|
||||
{
|
||||
|
||||
@ -281,7 +281,9 @@ public static class StoryIntelligenceIllustrationCompatibility
|
||||
if (ContainsAny(text, "passenger seat")) return "PassengerSeat";
|
||||
if (ContainsAny(text, "rear seat", "back seat")) return "RearSeat";
|
||||
if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "boot", "cabin", "dashboard")) return "VehicleInterior";
|
||||
if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom";
|
||||
if (ContainsAny(text, "ladies toilet", "ladies toilets", "gents toilet", "gents toilets", "public toilet", "public toilets", "pub toilet", "pub toilets", "bar toilet", "bar toilets", "toilet cubicle", "toilet cubicles", "washroom in the pub", "washroom in the bar")) return "PublicToilet";
|
||||
if (ContainsAny(text, "bathroom")) return "Bathroom";
|
||||
if (ContainsAny(text, "toilet", "washroom")) return "PublicToilet";
|
||||
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
|
||||
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
|
||||
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
|
||||
@ -321,7 +323,9 @@ public static class StoryIntelligenceIllustrationCompatibility
|
||||
if (ContainsAny(text, "passenger seat")) return "PassengerSeat";
|
||||
if (ContainsAny(text, "rear seat", "back seat")) return "RearSeat";
|
||||
if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "boot", "cabin", "dashboard")) return "VehicleInterior";
|
||||
if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom";
|
||||
if (ContainsAny(text, "ladies toilet", "ladies toilets", "gents toilet", "gents toilets", "public toilet", "public toilets", "pub toilet", "pub toilets", "bar toilet", "bar toilets", "toilet cubicle", "toilet cubicles", "washroom in the pub", "washroom in the bar")) return "PublicToilet";
|
||||
if (ContainsAny(text, "bathroom")) return "Bathroom";
|
||||
if (ContainsAny(text, "toilet", "washroom")) return "PublicToilet";
|
||||
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
|
||||
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
|
||||
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
|
||||
@ -429,7 +433,6 @@ public static class StoryIntelligenceIllustrationCompatibility
|
||||
|
||||
public static bool LocationCompatible(string requestedType, string candidateType)
|
||||
=> requestedType == candidateType
|
||||
|| (requestedType is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" && candidateType is "Road" or "Lane" or "Street")
|
||||
|| (requestedType is "DriverSeat" or "PassengerSeat" or "RearSeat" && candidateType == "VehicleInterior")
|
||||
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|
||||
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior")
|
||||
|
||||
@ -620,6 +620,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
private static IllustrationGenerationSpecification LocationDemandSpecification(string code, string demandKey, string type)
|
||||
{
|
||||
var exteriorTransport = type is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" or "DualCarriageway" or "Motorway" or "Bridge" or "Flyover" or "Underpass";
|
||||
var publicToilet = type is "PublicToilet";
|
||||
var readableType = ReadableToken(type).ToLowerInvariant();
|
||||
return new()
|
||||
{
|
||||
@ -629,9 +630,13 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
Description = "Demand-created reusable location illustration for Story Intelligence matching.",
|
||||
VisualSubject = exteriorTransport
|
||||
? $"A clear reusable exterior {readableType} road environment suitable for manuscript visualisation, with visible road surface, kerbs or verges, exterior daylight or street lighting, and no indoor corridor, hallway, doors-lined passage or building interior."
|
||||
: publicToilet
|
||||
? "A clear reusable public toilet or pub/bar washroom interior with cubicles or sinks, commercial fixtures, practical lighting, no domestic bath, no home bathroom styling."
|
||||
: $"A clear reusable {readableType} environment suitable for manuscript visualisation.",
|
||||
Composition = exteriorTransport
|
||||
? "single readable exterior transport environment, road type obvious at thumbnail size, no prominent people, no readable text, not an indoor corridor"
|
||||
: publicToilet
|
||||
? "single readable public washroom environment, cubicles or sink area obvious at thumbnail size, no bath, no domestic home decor, no prominent people"
|
||||
: "single readable environment, no prominent people, strong central focal point, suitable for circular and wide crops",
|
||||
Mood = "story-ready, atmospheric but clear, visually specific rather than generic",
|
||||
LocationType = type,
|
||||
|
||||
@ -635,6 +635,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
|
||||
if (type is "DrivingTestCentre" or "PoliceStation" or "DerelictHospital" or "Hospital" or "Church" or "Restaurant" or "Pub"
|
||||
or "Workshop" or "Garage" or "HouseExterior" or "Office" or "School" or "WaitingRoom" or "CarPark" or "Shop"
|
||||
or "PublicToilet"
|
||||
or "Kitchen" or "Bathroom" or "Bedroom" or "LivingRoom" or "Hallway" or "Stairwell" or "VehicleInterior"
|
||||
or "DriverSeat" or "PassengerSeat" or "RearSeat" or "FlatInterior")
|
||||
{
|
||||
@ -2274,6 +2275,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
{
|
||||
var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant();
|
||||
var type = StoryIntelligenceIllustrationCompatibility.LocationType(value);
|
||||
if (type == "PublicToilet") return "loc-generic-public-toilet";
|
||||
if (type == "Bathroom") return "loc-generic-bathroom";
|
||||
if (type == "Kitchen") return "loc-generic-kitchen";
|
||||
if (type == "WaitingRoom") return "loc-generic-waiting-room";
|
||||
@ -2334,6 +2336,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
private static string LocationFallback(string name)
|
||||
=> LocationLibraryCode(name, null, null) switch
|
||||
{
|
||||
"loc-generic-public-toilet" => string.Empty,
|
||||
"loc-laundry-utility-room" => $"{FallbackRoot}/location-laundry.svg",
|
||||
"loc-narrow-stairwell" => $"{FallbackRoot}/location-stairwell.svg",
|
||||
"loc-wet-car-park" => $"{FallbackRoot}/location-car-park.svg",
|
||||
|
||||
@ -1301,6 +1301,7 @@ public sealed class StoryMemoryService(
|
||||
"Underpass" => $"A clear {setting} road or pedestrian underpass matching '{nameHint}', concrete structure and readable path or carriageway, no prominent people.",
|
||||
"Bridge" => $"A clear {setting} road bridge scene matching '{nameHint}', bridge structure and readable road context, no prominent people.",
|
||||
"CarPark" => $"A clear {setting} car park scene matching '{nameHint}', marked bays and parked cars, no prominent people.",
|
||||
"PublicToilet" => $"A clear {setting} public toilet or pub/bar washroom matching '{nameHint}', cubicles or sinks, commercial fixtures, no domestic bath, no home bathroom styling, no prominent people.",
|
||||
_ => $"A clear, reusable {SplitWords(locationType).ToLowerInvariant()} environment matching '{nameHint}'."
|
||||
};
|
||||
}
|
||||
@ -1308,6 +1309,8 @@ public sealed class StoryMemoryService(
|
||||
private static string LocationComposition(string locationType)
|
||||
=> locationType is "Road" or "Lane" or "Street" or "Motorway" or "Flyover" or "Underpass" or "Bridge"
|
||||
? "one readable exterior transport environment, road type obvious at thumbnail size, landscape-friendly crop, no text or readable signage"
|
||||
: locationType is "PublicToilet"
|
||||
? "one readable public washroom environment, cubicles or sink area obvious at thumbnail size, no bath, no domestic home decor"
|
||||
: "one readable focal environment, no prominent people, cinematic but bright enough for a small interface thumbnail";
|
||||
|
||||
private static string InferInteriorExterior(string semanticType)
|
||||
|
||||
@ -43,7 +43,8 @@ public sealed class WordCompanionService(
|
||||
ICurrentUserService currentUser) : IWordCompanionService
|
||||
{
|
||||
private const int CharacterDiscoveryMinimumMentions = 2;
|
||||
private const int CharacterDiscoverySuggestionLimit = 50;
|
||||
private const int CharacterCreationSelectionLimit = 250;
|
||||
private const int RuntimeSuggestionSelectionLimit = 50;
|
||||
private const int CharacterDiscoveryExcludedLimit = 30;
|
||||
private static readonly Regex CharacterCandidateRegex = new(
|
||||
@"(?<!['’\p{L}])(?:(?:Mr|Mrs|Ms|Miss|Dr|Rev|Fr|Sir|Lady|Lord|DS|DI|DC|PC|Professor|Aunt|Uncle)\.?\s+)?[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?(?:\s+[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?){0,2}(?=(?:['’]s)?(?!\p{L})|(?!(?:['’]))[^\p{L}]|$)",
|
||||
@ -295,7 +296,7 @@ public sealed class WordCompanionService(
|
||||
request.CharacterIds = request.CharacterIds
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(CharacterDiscoverySuggestionLimit)
|
||||
.Take(RuntimeSuggestionSelectionLimit)
|
||||
.ToList();
|
||||
|
||||
return request.DocumentGuid == Guid.Empty
|
||||
@ -325,7 +326,7 @@ public sealed class WordCompanionService(
|
||||
request.AssetIds = request.AssetIds
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(CharacterDiscoverySuggestionLimit)
|
||||
.Take(RuntimeSuggestionSelectionLimit)
|
||||
.ToList();
|
||||
|
||||
return request.DocumentGuid == Guid.Empty
|
||||
@ -355,7 +356,7 @@ public sealed class WordCompanionService(
|
||||
request.LocationIds = request.LocationIds
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(CharacterDiscoverySuggestionLimit)
|
||||
.Take(RuntimeSuggestionSelectionLimit)
|
||||
.ToList();
|
||||
|
||||
return request.DocumentGuid == Guid.Empty
|
||||
@ -553,7 +554,7 @@ public sealed class WordCompanionService(
|
||||
.Select(NormalizeCandidateName)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name) && name.Length <= 200)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(CharacterDiscoverySuggestionLimit)
|
||||
.Take(CharacterCreationSelectionLimit)
|
||||
.ToList();
|
||||
|
||||
return request.CandidateNames.Count == 0
|
||||
@ -631,7 +632,6 @@ public sealed class WordCompanionService(
|
||||
.ThenByDescending(item => ConfidenceSortValue(item.Confidence))
|
||||
.ThenByDescending(item => item.MentionCount)
|
||||
.ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(CharacterDiscoverySuggestionLimit)
|
||||
.ToList();
|
||||
|
||||
if (includeExcluded)
|
||||
|
||||
@ -501,7 +501,7 @@
|
||||
detail: relationship.state,
|
||||
tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging"
|
||||
}));
|
||||
renderInsights(dom.relationships, relationships.slice(0, 1));
|
||||
renderInsights(dom.relationships, relationships.slice(0, 3));
|
||||
}
|
||||
|
||||
function updateKnowledgeThreads(scene) {
|
||||
@ -683,8 +683,8 @@
|
||||
const occupiedRects = layoutRects(stageRect);
|
||||
|
||||
scene.relationships
|
||||
.filter((relationship) => relationship.weight >= 72)
|
||||
.slice(0, 2)
|
||||
.filter((relationship) => relationship.weight >= 54)
|
||||
.slice(0, 5)
|
||||
.forEach((relationship, index) => {
|
||||
const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`);
|
||||
const target = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.targetId)}"]`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user