Phase 21T - Forensic Character Understanding Audit
This commit is contained in:
parent
d40c607d9b
commit
311fbcbbb0
@ -59,6 +59,7 @@ var tests = new (string Name, Action Test)[]
|
||||
("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly),
|
||||
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
|
||||
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments),
|
||||
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
|
||||
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
|
||||
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
||||
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
||||
@ -674,6 +675,16 @@ static IllustrationCharacterMetadata Metadata(string code, string ageBand, strin
|
||||
IllustrationPromptBuilder.CurrentTemplateVersion,
|
||||
"Test");
|
||||
|
||||
static StoryMemoryCharacterAttribute Attribute(string type, string value, decimal confidence, bool isExplicit, int sceneResultId)
|
||||
=> new()
|
||||
{
|
||||
AttributeType = type,
|
||||
NormalisedValue = value,
|
||||
Confidence = confidence,
|
||||
IsExplicit = isExplicit,
|
||||
SourceSceneResultID = sceneResultId
|
||||
};
|
||||
|
||||
static IReadOnlyList<string> Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata)
|
||||
=> StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false);
|
||||
|
||||
@ -970,6 +981,44 @@ static void Phase21SUsesDurableStoryMemoryAssignments()
|
||||
Assert(development.Contains("RebuildImportSessionAsync", StringComparison.Ordinal), "Development rebuild action must call the durable memory rebuild.");
|
||||
}
|
||||
|
||||
static void Phase21TRepairsDurableCharacterUnderstanding()
|
||||
{
|
||||
var maggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
|
||||
"Maggie",
|
||||
[
|
||||
Attribute("AgeBand", "Adult", 0.88m, false, 10),
|
||||
Attribute("AgeBand", "OlderTeen", 0.98m, true, 20),
|
||||
Attribute("Presentation", "Feminine", 0.98m, true, 1),
|
||||
Attribute("HairColour", "Red", 0.82m, true, 20),
|
||||
Attribute("HairLength", "Long", 0.72m, true, 20)
|
||||
]);
|
||||
Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Explicit later teen evidence should win, got {maggie.AgeBand}.");
|
||||
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Durable red-hair evidence should reach resolver, got {maggie.HairColour}.");
|
||||
Assert(maggie.HairLength == "Long", $"Durable hair length should reach resolver, got {maggie.HairLength}.");
|
||||
|
||||
var unknown = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches."], []);
|
||||
var childCandidate = Metadata("char-demand-child-feminine", "Child", "Feminine");
|
||||
Assert(Rejected(unknown, childCandidate).Any(reason => reason.Contains("unknown age", StringComparison.OrdinalIgnoreCase)), "Unknown named characters must reject child portraits.");
|
||||
|
||||
var adult = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson is a teacher."], []);
|
||||
var teenCandidate = Metadata("char-demand-olderteen-feminine", "OlderTeen", "Feminine");
|
||||
Assert(Rejected(adult, teenCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Adult title/occupation evidence must reject teen portraits.");
|
||||
|
||||
var olderTeen = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Colin", ["Colin is a learner driver at the driving test centre."], []);
|
||||
var childMasculineCandidate = Metadata("char-demand-child-masculine", "Child", "Masculine");
|
||||
Assert(Rejected(olderTeen, childMasculineCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Older teen evidence must reject child portraits.");
|
||||
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("Group of girls"), "Group of girls should not reserve a unique character portrait.");
|
||||
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("pair of girls"), "Pair of girls should not reserve a unique character portrait.");
|
||||
|
||||
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs"));
|
||||
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
|
||||
Assert(service.Contains("BuildCrossSceneIdentityAliases", StringComparison.Ordinal), "Rebuild should repair cross-scene descriptive-to-named character identities from saved analysis.");
|
||||
Assert(service.Contains("ExtensionEvidence(sceneCharacter.ExtensionData)", StringComparison.Ordinal), "Saved scene appearance extension data must be included in durable evidence.");
|
||||
Assert(snapshot.Contains("NonPovRole(memoryCharacter.Role)", StringComparison.Ordinal), "Carried memory characters must not keep POV role outside the resolved current POV.");
|
||||
Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Live and replay must apply durable Story Memory assignments.");
|
||||
}
|
||||
|
||||
static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
|
||||
{
|
||||
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
||||
|
||||
@ -99,8 +99,8 @@ public sealed class StoryMemoryRepository(ISqlConnectionFactory connectionFactor
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET
|
||||
DisplayName = CASE WHEN LEN(@DisplayName) > LEN(target.DisplayName) THEN @DisplayName ELSE target.DisplayName END,
|
||||
EntityContext = @EntityContext,
|
||||
SourceSectionType = @SourceSectionType,
|
||||
EntityContext = CASE WHEN target.EntityContext = N'Story' OR @EntityContext = N'Story' THEN N'Story' ELSE @EntityContext END,
|
||||
SourceSectionType = CASE WHEN target.SourceSectionType = N'Story' OR @SourceSectionType = N'Story' THEN N'Story' ELSE @SourceSectionType END,
|
||||
IsNamed = CASE WHEN @IsNamed = 1 THEN 1 ELSE target.IsNamed END,
|
||||
IsGroupEntity = CASE WHEN @IsGroupEntity = 1 THEN 1 ELSE target.IsGroupEntity END,
|
||||
IsResolved = CASE WHEN @IsResolved = 1 THEN 1 ELSE target.IsResolved END,
|
||||
|
||||
@ -172,6 +172,12 @@ public static class StoryIntelligenceIllustrationCompatibility
|
||||
rejected.Add($"age hard mismatch: wanted {evidence.AgeBand}, candidate {candidate.AgeBand}");
|
||||
}
|
||||
|
||||
if (string.Equals(evidence.AgeBand, AgeBands.Unknown, StringComparison.OrdinalIgnoreCase)
|
||||
&& candidate.AgeBand is AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen)
|
||||
{
|
||||
rejected.Add("unknown age requires adult-compatible artwork unless explicit child or teen evidence exists");
|
||||
}
|
||||
|
||||
if (evidence.HasStrongPresentationEvidence
|
||||
&& PresentationIncompatible(evidence.Presentation, candidate.Presentation))
|
||||
{
|
||||
@ -372,7 +378,12 @@ public static class StoryIntelligenceIllustrationCompatibility
|
||||
public static bool IsGroupEntity(string? value)
|
||||
{
|
||||
var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return clean.EndsWith('s')
|
||||
return clean.StartsWith("group of ", StringComparison.Ordinal)
|
||||
|| clean.StartsWith("pair of ", StringComparison.Ordinal)
|
||||
|| clean.StartsWith("two ", StringComparison.Ordinal)
|
||||
|| clean.Contains(" group of ", StringComparison.Ordinal)
|
||||
|| clean.Contains(" / group of ", StringComparison.Ordinal)
|
||||
|| clean.EndsWith('s')
|
||||
&& (clean.Contains("social worker", StringComparison.Ordinal)
|
||||
|| clean.Contains("neighbour", StringComparison.Ordinal)
|
||||
|| clean.Contains("neighbor", StringComparison.Ordinal)
|
||||
@ -380,6 +391,7 @@ public static class StoryIntelligenceIllustrationCompatibility
|
||||
|| clean.Contains("student", StringComparison.Ordinal)
|
||||
|| clean.Contains("examiner", StringComparison.Ordinal)
|
||||
|| clean.Contains("uniformed", StringComparison.Ordinal)
|
||||
|| clean.Contains("girl", StringComparison.Ordinal)
|
||||
|| clean.Contains("responder", StringComparison.Ordinal))
|
||||
|| clean is "crowd" or "uniformed people" or "police" or "social services";
|
||||
}
|
||||
@ -397,6 +409,11 @@ public static class StoryIntelligenceIllustrationCompatibility
|
||||
if (ContainsAny(text, "teenager", "teenage")) return new(AgeBands.OlderTeen, 0.86m);
|
||||
if (ContainsAny(text, "young adult", "young woman", "young man")) return new(AgeBands.YoungAdult, 0.82m);
|
||||
if (ContainsAny(text, "middle-aged", "middle aged", "mature adult")) return new(AgeBands.MatureAdult, 0.86m);
|
||||
if (ContainsAny(text, "driving test centre", "driving test center", "learner driver", "driving test")
|
||||
&& !ContainsAny(text, " is a driving examiner", " as a driving examiner", " female driving examiner", " male driving examiner", " driving instructor", " is an instructor", " as an instructor"))
|
||||
{
|
||||
return new(AgeBands.OlderTeen, 0.9m);
|
||||
}
|
||||
if (ContainsAny(text, " mr ", " mr. ", " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " doctor", " dr ", " dr. ", " professor", " driving examiner", " teacher", " detective", " police officer", " nurse", " receptionist", " solicitor", " landlord", " manager", "social worker")) return new(AgeBands.Adult, 0.88m);
|
||||
if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m);
|
||||
return EvidenceSignal.Unknown;
|
||||
@ -542,8 +559,8 @@ public static class StoryIntelligenceIllustrationCompatibility
|
||||
=> (evidence, candidate) switch
|
||||
{
|
||||
(AgeBands.Child, not AgeBands.Child) => true,
|
||||
(AgeBands.YoungTeen, AgeBands.YoungAdult or AgeBands.Adult or AgeBands.MatureAdult or AgeBands.Senior) => true,
|
||||
(AgeBands.OlderTeen, AgeBands.MatureAdult or AgeBands.Senior) => true,
|
||||
(AgeBands.YoungTeen, AgeBands.Child or AgeBands.YoungAdult or AgeBands.Adult or AgeBands.MatureAdult or AgeBands.Senior) => true,
|
||||
(AgeBands.OlderTeen, AgeBands.Child or AgeBands.YoungTeen or AgeBands.MatureAdult or AgeBands.Senior) => true,
|
||||
(AgeBands.Adult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
|
||||
(AgeBands.MatureAdult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
|
||||
(AgeBands.Senior, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen or AgeBands.YoungAdult) => true,
|
||||
|
||||
@ -532,7 +532,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
Id = CharacterId(memoryCharacter.Name),
|
||||
LibraryCode = libraryCode,
|
||||
Name = memoryCharacter.Name,
|
||||
Role = memoryCharacter.Role,
|
||||
Role = string.Equals(CharacterId(memoryCharacter.Name), CharacterId(povName), StringComparison.OrdinalIgnoreCase)
|
||||
? "POV character"
|
||||
: NonPovRole(memoryCharacter.Role),
|
||||
Relevance = memoryCharacter.Relevance,
|
||||
Weight = Math.Max(38, memoryCharacter.Weight - 18),
|
||||
ImagePath = CharacterFallback(memoryCharacter.Name)
|
||||
@ -550,10 +552,16 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
private static string NonPovRole(SceneIntelligenceCharacter character)
|
||||
{
|
||||
var role = FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character");
|
||||
return NonPovRole(role, character.MentionedOnly == true);
|
||||
}
|
||||
|
||||
private static string NonPovRole(string? role, bool referenced = false)
|
||||
{
|
||||
role = FirstConfigured(role, referenced ? "Referenced" : "Scene character");
|
||||
return role.Contains("pov", StringComparison.OrdinalIgnoreCase)
|
||||
|| role.Contains("point of view", StringComparison.OrdinalIgnoreCase)
|
||||
|| role.Contains("narrator", StringComparison.OrdinalIgnoreCase)
|
||||
? character.MentionedOnly == true ? "Referenced" : "Scene character"
|
||||
? referenced ? "Referenced" : "Scene character"
|
||||
: role;
|
||||
}
|
||||
|
||||
@ -1605,13 +1613,20 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
var characters = await storyMemoryRepository.ListCharactersAsync(importSessionId);
|
||||
var locations = await storyMemoryRepository.ListLocationsAsync(importSessionId);
|
||||
var assets = await storyMemoryRepository.ListAssetsAsync(importSessionId);
|
||||
var attributes = await storyMemoryRepository.ListCharacterAttributesAsync(importSessionId);
|
||||
var characterIds = characters.ToDictionary(item => item.StoryMemoryCharacterID, item => item.CanonicalIdentityKey);
|
||||
var locationIds = locations.ToDictionary(item => item.StoryMemoryLocationID, item => item.CanonicalIdentityKey);
|
||||
var assetIds = assets.ToDictionary(item => item.StoryMemoryAssetID, item => item.CanonicalIdentityKey);
|
||||
var characterEvidence = characters.ToDictionary(
|
||||
item => item.CanonicalIdentityKey,
|
||||
item => StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
|
||||
item.DisplayName,
|
||||
attributes.Where(attribute => attribute.StoryMemoryCharacterID == item.StoryMemoryCharacterID)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var catalogue = (await illustrationRepository.ListAsync(new IllustrationLibraryFilter()))
|
||||
.ToDictionary(item => item.IllustrationLibraryItemID);
|
||||
|
||||
var characterAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Character, characterIds, catalogue);
|
||||
var characterAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Character, characterIds, catalogue, characterEvidence);
|
||||
var locationAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Location, locationIds, catalogue);
|
||||
var assetAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Asset, assetIds, catalogue);
|
||||
|
||||
@ -1634,7 +1649,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
IReadOnlyList<StoryMemoryIllustrationAssignment> assignments,
|
||||
string entityType,
|
||||
IReadOnlyDictionary<int, string> entityKeys,
|
||||
IReadOnlyDictionary<int, IllustrationLibraryItem> catalogue)
|
||||
IReadOnlyDictionary<int, IllustrationLibraryItem> catalogue,
|
||||
IReadOnlyDictionary<string, CharacterEvidenceProfile>? characterEvidence = null)
|
||||
{
|
||||
var lookup = new Dictionary<string, AssignmentImage>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var assignment in assignments.Where(item => item.EntityType == entityType && item.IllustrationLibraryItemID.HasValue))
|
||||
@ -1651,7 +1667,25 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
continue;
|
||||
}
|
||||
|
||||
lookup[entityKey] = new AssignmentImage(item.IllustrationLibraryItemID, item.StableCode, item.Status, publicUrl, assignment.Confidence, assignment.MatchScore, assignment.AssignmentReason);
|
||||
CharacterEvidenceProfile? evidence = null;
|
||||
characterEvidence?.TryGetValue(entityKey, out evidence);
|
||||
var currentEvidence = evidence is null
|
||||
? null
|
||||
: $"age={evidence.AgeBand}; presentation={evidence.Presentation}; hair={evidence.HairColour}; hairLength={evidence.HairLength}; skin={evidence.SkinTone}";
|
||||
lookup[entityKey] = new AssignmentImage(
|
||||
item.IllustrationLibraryItemID,
|
||||
item.StableCode,
|
||||
item.Status,
|
||||
publicUrl,
|
||||
assignment.Confidence,
|
||||
assignment.MatchScore,
|
||||
assignment.AssignmentReason,
|
||||
evidence?.AgeBand,
|
||||
evidence?.Presentation,
|
||||
currentEvidence,
|
||||
assignment.EvidenceVersion,
|
||||
assignment.AssignmentStatus,
|
||||
assignment.IsFallback);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
@ -1709,8 +1743,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
AssignmentConfidence = assignment.Confidence,
|
||||
MatchingScore = assignment.MatchScore,
|
||||
ReasonChosen = assignment.Reason,
|
||||
PreviousIllustration = assignment.StableCode,
|
||||
CurrentEvidence = assignment.CurrentEvidence,
|
||||
PreviousEvidence = $"assignment evidence version {assignment.EvidenceVersion}",
|
||||
ResolvedAgeBand = assignment.ResolvedAgeBand,
|
||||
ResolvedPresentation = assignment.ResolvedPresentation,
|
||||
DemandStatus = "Read from durable Story Memory",
|
||||
IllustrationAllocationStatus = status
|
||||
IllustrationAllocationStatus = assignment.IsFallback ? assignment.AssignmentStatus : status
|
||||
};
|
||||
|
||||
private string? ResolveExistingPublicUploadUrl(string? storedPath)
|
||||
@ -2126,6 +2165,17 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (words.Length == 2
|
||||
&& !IsTitleWord(words[0])
|
||||
&& name is not null
|
||||
&& name.Trim().Length > 0
|
||||
&& char.IsUpper(name.Trim()[0])
|
||||
&& name.Trim().All(ch => char.IsLetter(ch) || char.IsWhiteSpace(ch) || ch == '\'' || ch == '\u2019'))
|
||||
{
|
||||
return words[0];
|
||||
}
|
||||
|
||||
if (clean is "mother" or "mum" or "mummy" or "her mother" or "the mother" or "my mother" or "my mum"
|
||||
|| clean.Contains("beth's mum", StringComparison.Ordinal)
|
||||
|| clean.Contains("beth's mother", StringComparison.Ordinal))
|
||||
@ -2146,6 +2196,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
return clean;
|
||||
}
|
||||
|
||||
private static bool IsTitleWord(string value)
|
||||
=> value is "mr" or "mrs" or "miss" or "ms" or "dr" or "doctor" or "sir" or "lady";
|
||||
|
||||
private static int CharacterWeight(SceneIntelligenceCharacter character)
|
||||
=> character.MentionedOnly == true ? 46 : ConfidenceWeight(character.Confidence, 72);
|
||||
|
||||
@ -2353,7 +2406,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
||||
string PublicUrl,
|
||||
decimal Confidence,
|
||||
int MatchScore,
|
||||
string? Reason);
|
||||
string? Reason,
|
||||
string? ResolvedAgeBand,
|
||||
string? ResolvedPresentation,
|
||||
string? CurrentEvidence,
|
||||
string EvidenceVersion,
|
||||
string AssignmentStatus,
|
||||
bool IsFallback);
|
||||
|
||||
private sealed record CharacterCodeProfile(
|
||||
string Code,
|
||||
|
||||
@ -58,6 +58,7 @@ public sealed class StoryMemoryService(
|
||||
await memory.ClearDerivedStateAsync(importSessionId);
|
||||
}
|
||||
|
||||
var identityAliases = BuildCrossSceneIdentityAliases(completedScenes);
|
||||
await memory.SetAppearancePreferenceAsync(importSessionId, options.AppearancePreference);
|
||||
var processed = await memory.ListProcessedSceneResultIdsAsync(importSessionId, ProcessorVersion);
|
||||
var processedCount = 0;
|
||||
@ -69,7 +70,7 @@ public sealed class StoryMemoryService(
|
||||
continue;
|
||||
}
|
||||
|
||||
await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene!, cancellationToken);
|
||||
await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene!, identityAliases, cancellationToken);
|
||||
await memory.MarkSceneResultProcessedAsync(importSessionId, scene.Result.SceneResultID, ProcessorVersion, StableContentHash(scene.Result.ParsedJson));
|
||||
processedCount++;
|
||||
}
|
||||
@ -114,7 +115,7 @@ public sealed class StoryMemoryService(
|
||||
return;
|
||||
}
|
||||
|
||||
await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene, cancellationToken);
|
||||
await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene, new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase), cancellationToken);
|
||||
await memory.MarkSceneResultProcessedAsync(importSessionId, sceneResultId, ProcessorVersion, StableContentHash(scene.Result.ParsedJson));
|
||||
await ResolveIllustrationsAsync(importSessionId, options, cancellationToken);
|
||||
}
|
||||
@ -136,10 +137,13 @@ public sealed class StoryMemoryService(
|
||||
.Select(item => item.IllustrationLibraryItemID!.Value)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var character in characters.Where(item => item.EntityContext == "Story" && !item.IsGroupEntity))
|
||||
foreach (var character in characters.Where(item => !item.IsGroupEntity))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var evidence = CharacterEvidence(character, attributes.Where(item => item.StoryMemoryCharacterID == character.StoryMemoryCharacterID));
|
||||
var evidence = ApplyAppearancePreference(
|
||||
CharacterEvidence(character, attributes.Where(item => item.StoryMemoryCharacterID == character.StoryMemoryCharacterID)),
|
||||
character.CanonicalIdentityKey,
|
||||
options.AppearancePreference);
|
||||
var existing = assignments.FirstOrDefault(item => item.EntityType == StoryMemoryEntityTypes.Character && item.EntityID == character.StoryMemoryCharacterID);
|
||||
var existingItem = existing?.IllustrationLibraryItemID is int existingId
|
||||
? catalogue.FirstOrDefault(item => item.IllustrationLibraryItemID == existingId)
|
||||
@ -228,7 +232,12 @@ public sealed class StoryMemoryService(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpsertSceneMemoryAsync(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedSceneResult result, SceneIntelligenceScene scene, CancellationToken cancellationToken)
|
||||
private async Task UpsertSceneMemoryAsync(
|
||||
StoryIntelligenceBookPipelineState session,
|
||||
StoryIntelligenceSavedSceneResult result,
|
||||
SceneIntelligenceScene scene,
|
||||
IReadOnlyDictionary<string, string> identityAliases,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = SourceSectionType(result, scene);
|
||||
var sourceSectionType = context == "Story" ? "Story" : "Authorial";
|
||||
@ -240,21 +249,29 @@ public sealed class StoryMemoryService(
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = CharacterKey(sceneCharacter.Name);
|
||||
var resolvedName = ResolveIdentityAlias(sceneCharacter.Name, identityAliases);
|
||||
var resolvedAliases = string.Equals(resolvedName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
|
||||
? sceneCharacter.Aliases
|
||||
: (sceneCharacter.Aliases ?? []).Concat([sceneCharacter.Name!]).ToList();
|
||||
var key = CharacterKey(resolvedName, resolvedAliases);
|
||||
var isGroup = StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sceneCharacter.Name);
|
||||
var resolvedPovName = ResolveScenePov(scene);
|
||||
var isPrimaryPov = string.Equals(resolvedPovName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(resolvedPovName, resolvedName, StringComparison.OrdinalIgnoreCase)
|
||||
|| (sceneCharacter.Aliases ?? []).Any(alias => string.Equals(resolvedPovName, alias, StringComparison.OrdinalIgnoreCase));
|
||||
var character = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(
|
||||
session.StoryIntelligenceBookPipelineID,
|
||||
session.ProjectID,
|
||||
session.BookID,
|
||||
key,
|
||||
sceneCharacter.Name.Trim(),
|
||||
resolvedName.Trim(),
|
||||
context,
|
||||
sourceSectionType,
|
||||
IsNamedCharacter(sceneCharacter.Name),
|
||||
isGroup,
|
||||
true,
|
||||
string.Equals(scene.PointOfView?.CharacterName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase),
|
||||
string.Equals(scene.PointOfView?.CharacterName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase),
|
||||
isPrimaryPov,
|
||||
isPrimaryPov,
|
||||
CharacterSignificance(sceneCharacter),
|
||||
result.SceneResultID));
|
||||
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, sceneCharacter.Name.Trim(), Normalise(sceneCharacter.Name), "Scene character name", result.SceneResultID, sceneCharacter.Confidence ?? 0.8m);
|
||||
@ -263,19 +280,24 @@ public sealed class StoryMemoryService(
|
||||
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias, Normalise(alias), "Scene character alias", result.SceneResultID, sceneCharacter.Confidence ?? 0.7m);
|
||||
}
|
||||
|
||||
var identityEvidence = new[] { sceneCharacter.Name, sceneCharacter.RoleInScene, sceneCharacter.Notes }
|
||||
var identityEvidence = new[] { sceneCharacter.Name, resolvedName, sceneCharacter.RoleInScene, sceneCharacter.Notes }
|
||||
.Concat(sceneCharacter.Actions ?? [])
|
||||
.Concat(sceneCharacter.Aliases ?? [])
|
||||
.Concat(ExtensionEvidence(sceneCharacter.ExtensionData))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!);
|
||||
var relationshipEvidence = (scene.Relationships ?? [])
|
||||
.Where(item => string.Equals(item.CharacterA, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase) || string.Equals(item.CharacterB, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(item => $"{item.RelationshipSignal} {item.Evidence}");
|
||||
var inferred = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(sceneCharacter.Name, identityEvidence, relationshipEvidence);
|
||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "AgeBand", inferred.AgeBand, inferred.AgeConfidence, false, "Deterministic evidence inference", result.SceneResultID);
|
||||
var evidenceName = string.Equals(resolvedName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
|
||||
? resolvedName
|
||||
: sceneCharacter.Name;
|
||||
var inferred = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(evidenceName, identityEvidence, relationshipEvidence);
|
||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "AgeBand", inferred.AgeBand, inferred.AgeConfidence, inferred.AgeConfidence >= 0.85m, "Deterministic evidence inference", result.SceneResultID);
|
||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "Presentation", inferred.Presentation, inferred.PresentationConfidence, inferred.PresentationConfidence >= 0.8m, "Deterministic evidence inference", result.SceneResultID);
|
||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", inferred.HairColour, 0.7m, false, "Deterministic evidence inference", result.SceneResultID);
|
||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "SkinTone", inferred.SkinTone, 0.7m, false, "Deterministic evidence inference", result.SceneResultID);
|
||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", inferred.HairLength, 0.6m, false, "Deterministic evidence inference", result.SceneResultID);
|
||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", inferred.HairColour, 0.82m, inferred.HairColour != StoryIntelligenceIllustrationCompatibility.HairColours.Unknown, "Deterministic evidence inference", result.SceneResultID);
|
||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "SkinTone", inferred.SkinTone, 0.8m, inferred.SkinTone != StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, "Deterministic evidence inference", result.SceneResultID);
|
||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", inferred.HairLength, 0.72m, inferred.HairLength != "Unknown", "Deterministic evidence inference", result.SceneResultID);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scene.Setting?.LocationName))
|
||||
@ -319,8 +341,10 @@ public sealed class StoryMemoryService(
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(relationship.CharacterA), relationship.CharacterA.Trim(), context, sourceSectionType, IsNamedCharacter(relationship.CharacterA), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(relationship.CharacterA), true, false, false, 40, result.SceneResultID));
|
||||
var target = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(relationship.CharacterB), relationship.CharacterB.Trim(), context, sourceSectionType, IsNamedCharacter(relationship.CharacterB), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(relationship.CharacterB), true, false, false, 40, result.SceneResultID));
|
||||
var sourceName = ResolveIdentityAlias(relationship.CharacterA, identityAliases);
|
||||
var targetName = ResolveIdentityAlias(relationship.CharacterB, identityAliases);
|
||||
var source = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(sourceName), sourceName.Trim(), context, sourceSectionType, IsNamedCharacter(sourceName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sourceName), true, false, false, 40, result.SceneResultID));
|
||||
var target = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(targetName), targetName.Trim(), context, sourceSectionType, IsNamedCharacter(targetName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(targetName), true, false, false, 40, result.SceneResultID));
|
||||
await memory.UpsertRelationshipAsync(session.StoryIntelligenceBookPipelineID, StoryMemoryEntityTypes.Character, source.StoryMemoryCharacterID, StoryMemoryEntityTypes.Character, target.StoryMemoryCharacterID, relationship.RelationshipSignal ?? "related", relationship.Confidence ?? 0.6m, !string.IsNullOrWhiteSpace(relationship.Evidence), result.SceneResultID);
|
||||
}
|
||||
}
|
||||
@ -410,12 +434,198 @@ public sealed class StoryMemoryService(
|
||||
return null;
|
||||
}
|
||||
|
||||
private CharacterEvidenceProfile CharacterEvidence(StoryMemoryCharacter character, IEnumerable<StoryMemoryCharacterAttribute> attributes)
|
||||
private static CharacterEvidenceProfile CharacterEvidence(StoryMemoryCharacter character, IEnumerable<StoryMemoryCharacterAttribute> attributes)
|
||||
=> ResolveCharacterEvidenceFromAttributes(character.DisplayName, attributes);
|
||||
|
||||
private static CharacterEvidenceProfile ApplyAppearancePreference(CharacterEvidenceProfile evidence, string identityKey, string preference)
|
||||
{
|
||||
var values = attributes.Select(item => $"{item.AttributeType} {item.NormalisedValue}").ToList();
|
||||
return StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(character.DisplayName, values, []);
|
||||
if (!string.Equals(evidence.SkinTone, StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return evidence;
|
||||
}
|
||||
|
||||
var skinTone = preference switch
|
||||
{
|
||||
StoryMemoryAppearancePreferences.PredominantlyLight => StoryIntelligenceIllustrationCompatibility.SkinTones.Light,
|
||||
StoryMemoryAppearancePreferences.PredominantlyMedium => StoryIntelligenceIllustrationCompatibility.SkinTones.Medium,
|
||||
StoryMemoryAppearancePreferences.PredominantlyDark => StoryIntelligenceIllustrationCompatibility.SkinTones.Dark,
|
||||
StoryMemoryAppearancePreferences.Balanced => BalancedSkinTone(identityKey),
|
||||
StoryMemoryAppearancePreferences.CustomMix => BalancedSkinTone(identityKey),
|
||||
_ => StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown
|
||||
};
|
||||
|
||||
return string.Equals(skinTone, StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, StringComparison.OrdinalIgnoreCase)
|
||||
? evidence
|
||||
: evidence with { SkinTone = skinTone };
|
||||
}
|
||||
|
||||
private static string BalancedSkinTone(string key)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
||||
return (bytes[0] % 3) switch
|
||||
{
|
||||
0 => StoryIntelligenceIllustrationCompatibility.SkinTones.Light,
|
||||
1 => StoryIntelligenceIllustrationCompatibility.SkinTones.Medium,
|
||||
_ => StoryIntelligenceIllustrationCompatibility.SkinTones.Dark
|
||||
};
|
||||
}
|
||||
|
||||
public static CharacterEvidenceProfile ResolveCharacterEvidenceFromAttributes(string displayName, IEnumerable<StoryMemoryCharacterAttribute> attributes)
|
||||
{
|
||||
var attributeList = attributes
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.AttributeType) && !string.IsNullOrWhiteSpace(item.NormalisedValue))
|
||||
.ToList();
|
||||
|
||||
var inferred = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(
|
||||
displayName,
|
||||
[displayName],
|
||||
[]);
|
||||
var age = PickAttribute(attributeList, "AgeBand", inferred.AgeBand, inferred.AgeConfidence);
|
||||
var presentation = PickAttribute(attributeList, "Presentation", inferred.Presentation, inferred.PresentationConfidence);
|
||||
var hairColour = PickAttribute(attributeList, "HairColour", inferred.HairColour, 0.1m);
|
||||
var skinTone = PickAttribute(attributeList, "SkinTone", inferred.SkinTone, 0.1m);
|
||||
var hairLength = PickAttribute(attributeList, "HairLength", inferred.HairLength, 0.1m);
|
||||
|
||||
return new(
|
||||
age.Value,
|
||||
presentation.Value,
|
||||
hairColour.Value,
|
||||
skinTone.Value,
|
||||
hairLength.Value,
|
||||
"Unknown",
|
||||
"Unknown",
|
||||
age.Confidence,
|
||||
presentation.Confidence,
|
||||
inferred.RelationshipConfidence,
|
||||
inferred.NameConfidence,
|
||||
inferred.PronounConfidence,
|
||||
inferred.Warnings);
|
||||
}
|
||||
|
||||
private static (string Value, decimal Confidence) PickAttribute(
|
||||
IReadOnlyList<StoryMemoryCharacterAttribute> attributes,
|
||||
string attributeType,
|
||||
string fallback,
|
||||
decimal fallbackConfidence)
|
||||
{
|
||||
var selected = attributes
|
||||
.Where(item => string.Equals(item.AttributeType, attributeType, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(item => item.IsExplicit)
|
||||
.ThenByDescending(item => item.Confidence)
|
||||
.ThenByDescending(item => item.SourceSceneResultID ?? 0)
|
||||
.FirstOrDefault();
|
||||
return selected is null
|
||||
? (fallback, fallbackConfidence)
|
||||
: (selected.NormalisedValue, selected.Confidence);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildCrossSceneIdentityAliases(IReadOnlyList<SceneResultWithParse> scenes)
|
||||
{
|
||||
var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var ordered = scenes
|
||||
.Where(item => item.Scene is not null)
|
||||
.OrderBy(item => item.Run.StoryIntelligenceRunID)
|
||||
.ThenBy(item => item.Result.TemporarySceneNumber)
|
||||
.ThenBy(item => item.Result.SceneResultID)
|
||||
.ToList();
|
||||
|
||||
for (var i = 0; i < ordered.Count - 1; i++)
|
||||
{
|
||||
var current = ordered[i];
|
||||
var next = ordered[i + 1];
|
||||
if (current.Run.StoryIntelligenceRunID != next.Run.StoryIntelligenceRunID
|
||||
|| current.Scene is null
|
||||
|| next.Scene is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var descriptive = (current.Scene.Characters ?? [])
|
||||
.Where(character => IsDescriptiveRedHairedGirl(character.Name))
|
||||
.Where(character => character.MentionedOnly != true)
|
||||
.ToList();
|
||||
if (descriptive.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var revealed = (next.Scene.Characters ?? [])
|
||||
.Where(character => IsSimpleNamedPerson(character.Name))
|
||||
.Where(character => CharacterText(character).Contains("returns", StringComparison.OrdinalIgnoreCase)
|
||||
|| CharacterText(character).Contains("greeting", StringComparison.OrdinalIgnoreCase)
|
||||
|| CharacterText(character).Contains("introduces", StringComparison.OrdinalIgnoreCase)
|
||||
|| CharacterText(character).Contains("bar", StringComparison.OrdinalIgnoreCase)
|
||||
|| CharacterText(character).Contains("friend", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(character => character.Confidence ?? 0)
|
||||
.FirstOrDefault();
|
||||
if (revealed?.Name is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var character in descriptive)
|
||||
{
|
||||
aliases[Normalise(character.Name)] = revealed.Name.Trim();
|
||||
foreach (var alias in character.Aliases ?? [])
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(alias))
|
||||
{
|
||||
aliases[Normalise(alias)] = revealed.Name.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private static string ResolveIdentityAlias(string? name, IReadOnlyDictionary<string, string> identityAliases)
|
||||
{
|
||||
var normalised = Normalise(name);
|
||||
return normalised.Length > 0 && identityAliases.TryGetValue(normalised, out var resolved)
|
||||
? resolved
|
||||
: name ?? string.Empty;
|
||||
}
|
||||
|
||||
private static bool IsDescriptiveRedHairedGirl(string? name)
|
||||
{
|
||||
var normalised = Normalise(name);
|
||||
var clean = normalised.Replace('-', ' ');
|
||||
return clean.Contains("red haired girl", StringComparison.OrdinalIgnoreCase)
|
||||
|| clean.Contains("girl in red", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsSimpleNamedPerson(string? name)
|
||||
{
|
||||
var clean = (name ?? string.Empty).Trim();
|
||||
if (clean.Length == 0 || !char.IsUpper(clean[0]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalised = Normalise(clean);
|
||||
if (StoryIntelligenceIllustrationCompatibility.IsGroupEntity(clean)
|
||||
|| normalised.Contains("girl", StringComparison.Ordinal)
|
||||
|| normalised.Contains("woman", StringComparison.Ordinal)
|
||||
|| normalised.Contains("man", StringComparison.Ordinal)
|
||||
|| normalised.Contains("narrator", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalised.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 2;
|
||||
}
|
||||
|
||||
private static string CharacterText(SceneIntelligenceCharacter character)
|
||||
=> string.Join(' ', new[]
|
||||
{
|
||||
character.Name,
|
||||
character.RoleInScene,
|
||||
character.Notes,
|
||||
string.Join(' ', character.Actions ?? [])
|
||||
}
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
|
||||
private (IllustrationLibraryItem? Item, int Score, bool IsCompatible) ScoreCharacter(IllustrationLibraryItem item, CharacterEvidenceProfile evidence, bool isNamed, bool alreadyUsed)
|
||||
{
|
||||
var metadata = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);
|
||||
@ -449,16 +659,37 @@ public sealed class StoryMemoryService(
|
||||
await memory.UpsertDemandAsync(new StoryMemoryIllustrationDemandSave(importSessionId, projectId, entityType, archetypeKey, 1, "Recorded", null, null));
|
||||
}
|
||||
|
||||
private static string CharacterKey(string? name) => $"character-result-{StableKey(CanonicalCharacterIdentity(name))}";
|
||||
private static string CharacterKey(string? name, IEnumerable<string>? aliases = null) => $"character-result-{StableKey(CanonicalCharacterIdentity(name, aliases))}";
|
||||
private static string LocationKey(string? name) => $"location-result-{StableKey(name)}";
|
||||
private static string AssetKey(string? name) => $"asset-result-{StableKey(name)}";
|
||||
private static string DemandKey(params string[] parts) => StableKey(string.Join("-", parts.Where(part => !string.IsNullOrWhiteSpace(part))));
|
||||
|
||||
private static string CanonicalCharacterIdentity(string? name)
|
||||
private static string CanonicalCharacterIdentity(string? name, IEnumerable<string>? aliases = null)
|
||||
{
|
||||
var clean = (name ?? string.Empty).Trim();
|
||||
if (clean.Length == 0) return "unknown";
|
||||
var lower = clean.ToLowerInvariant();
|
||||
var aliasList = (aliases ?? [])
|
||||
.Select(alias => (Raw: alias.Trim(), Normalised: Normalise(alias)))
|
||||
.Where(alias => alias.Normalised.Length > 0)
|
||||
.ToList();
|
||||
var normalised = Normalise(clean);
|
||||
var words = normalised.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (words.Length == 2
|
||||
&& !IsTitleWord(words[0])
|
||||
&& aliasList.Any(alias => string.Equals(alias.Normalised, words[0], StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return words[0];
|
||||
}
|
||||
|
||||
if (words.Length == 2
|
||||
&& !IsTitleWord(words[0])
|
||||
&& char.IsUpper(clean[0])
|
||||
&& clean.All(ch => char.IsLetter(ch) || char.IsWhiteSpace(ch) || ch == '\'' || ch == '\u2019'))
|
||||
{
|
||||
return words[0];
|
||||
}
|
||||
|
||||
return lower switch
|
||||
{
|
||||
"i" or "me" or "myself" or "narrator" => "pov-character",
|
||||
@ -468,6 +699,65 @@ public sealed class StoryMemoryService(
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsTitleWord(string value)
|
||||
=> value is "mr" or "mrs" or "miss" or "ms" or "dr" or "doctor" or "sir" or "lady";
|
||||
|
||||
private static string? ResolveScenePov(SceneIntelligenceScene scene)
|
||||
{
|
||||
var raw = (scene.PointOfView?.CharacterName ?? string.Empty).Trim();
|
||||
if (raw.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalised = Normalise(raw);
|
||||
if (normalised is not ("i" or "me" or "my" or "myself" or "narrator" or "first person narrator" or "unnamed narrator"))
|
||||
{
|
||||
return raw;
|
||||
}
|
||||
|
||||
var evidence = string.Join(' ', new[]
|
||||
{
|
||||
scene.PointOfView?.Evidence,
|
||||
scene.Summary?.Short,
|
||||
scene.Summary?.Detailed,
|
||||
scene.ScenePurpose?.ObservedFunction
|
||||
}.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
var named = (scene.Characters ?? [])
|
||||
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
|
||||
.Select(character => character.Name!.Trim())
|
||||
.Where(name => Normalise(name) is not ("i" or "me" or "my" or "myself" or "narrator"))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return named.FirstOrDefault(name => evidence.Contains(name, StringComparison.OrdinalIgnoreCase))
|
||||
?? (named.Count == 1 ? named[0] : null);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ExtensionEvidence(Dictionary<string, JsonElement>? extensionData)
|
||||
{
|
||||
if (extensionData is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in extensionData)
|
||||
{
|
||||
if (!key.Contains("appearance", StringComparison.OrdinalIgnoreCase)
|
||||
&& !key.Contains("description", StringComparison.OrdinalIgnoreCase)
|
||||
&& !key.Contains("physical", StringComparison.OrdinalIgnoreCase)
|
||||
&& !key.Contains("hair", StringComparison.OrdinalIgnoreCase)
|
||||
&& !key.Contains("skin", StringComparison.OrdinalIgnoreCase)
|
||||
&& !key.Contains("age", StringComparison.OrdinalIgnoreCase)
|
||||
&& !key.Contains("gender", StringComparison.OrdinalIgnoreCase)
|
||||
&& !key.Contains("presentation", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return $"{key}: {value}";
|
||||
}
|
||||
}
|
||||
|
||||
private static string Normalise(string? value)
|
||||
=> string.Join(' ', (value ?? string.Empty).Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user