Phase 21T - Forensic Character Understanding Audit

This commit is contained in:
Nick Beckley 2026-07-24 20:12:50 +00:00
parent d40c607d9b
commit 311fbcbbb0
5 changed files with 449 additions and 34 deletions

View File

@ -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"));

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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));