diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index c3f9566..04cce24 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -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 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")); diff --git a/PlotLine/Data/StoryMemoryRepository.cs b/PlotLine/Data/StoryMemoryRepository.cs index c0eba59..7ea33d4 100644 --- a/PlotLine/Data/StoryMemoryRepository.cs +++ b/PlotLine/Data/StoryMemoryRepository.cs @@ -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, diff --git a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs index 695e186..d170a3b 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs @@ -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, diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index b858553..955b222 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -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 assignments, string entityType, IReadOnlyDictionary entityKeys, - IReadOnlyDictionary catalogue) + IReadOnlyDictionary catalogue, + IReadOnlyDictionary? characterEvidence = null) { var lookup = new Dictionary(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, diff --git a/PlotLine/Services/StoryMemoryServices.cs b/PlotLine/Services/StoryMemoryServices.cs index afe6dc2..e63a636 100644 --- a/PlotLine/Services/StoryMemoryServices.cs +++ b/PlotLine/Services/StoryMemoryServices.cs @@ -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(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 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 attributes) + private static CharacterEvidenceProfile CharacterEvidence(StoryMemoryCharacter character, IEnumerable 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 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 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 BuildCrossSceneIdentityAliases(IReadOnlyList scenes) + { + var aliases = new Dictionary(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 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? 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? 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 ExtensionEvidence(Dictionary? 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));