Phase 21J.2 - Character Identity and Illustration Matching

This commit is contained in:
Nick Beckley 2026-07-12 21:33:32 +00:00
parent b513bd7df0
commit 69e4ac8201
6 changed files with 529 additions and 48 deletions

View File

@ -33,6 +33,8 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
}
var metadata = new Dictionary<string, string>(specification.Metadata, StringComparer.OrdinalIgnoreCase);
AddMetadata("category", specification.Category);
AddMetadata("metadataConfidence", "High");
if (string.Equals(specification.Category, IllustrationLibraryCategories.Character, StringComparison.OrdinalIgnoreCase))
{
AddMetadata("ageBand", specification.ApparentAgeBand);

View File

@ -1,5 +1,7 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Security.Cryptography;
using System.Text;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
@ -14,11 +16,13 @@ public interface IStoryIntelligenceIllustrationMatchingService
public sealed class StoryIntelligenceIllustrationMatchingService(
IIllustrationLibraryRepository library,
IStoryIntelligenceIllustrationMatchingRepository assignments,
IIllustrationPromptBuilder promptBuilder,
IUploadStorageService uploadStorage,
ILogger<StoryIntelligenceIllustrationMatchingService> logger) : IStoryIntelligenceIllustrationMatchingService
{
private const int SuitableScore = 48;
private const int ReassignmentMargin = 24;
private const int DemandGenerationThreshold = 3;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@ -53,12 +57,25 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
.Where(item => item.IllustrationLibraryItemID.HasValue)
.Select(item => item.IllustrationLibraryItemID!.Value)
.ToHashSet();
var duplicateExistingItemIds = existingAssignments.Values
.Where(item => item.IllustrationLibraryItemID.HasValue)
.GroupBy(item => item.IllustrationLibraryItemID!.Value)
.Where(group => group.Select(item => item.CharacterKey).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
.Select(group => group.Key)
.ToHashSet();
var retainedExistingItemIds = new HashSet<int>();
var observations = BuildCharacterObservations(prototype);
foreach (var observation in observations.Values.OrderByDescending(item => item.Significance).ThenBy(item => item.Name))
{
var existing = existingAssignments.GetValueOrDefault(observation.CharacterKey);
var ownExistingItemId = existing?.IllustrationLibraryItemID;
if (ownExistingItemId.HasValue && duplicateExistingItemIds.Contains(ownExistingItemId.Value) && !retainedExistingItemIds.Add(ownExistingItemId.Value))
{
ownExistingItemId = null;
existing = null;
}
var allScores = catalogue
.Select(candidate => Score(
candidate,
@ -75,14 +92,14 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId)
: null;
var best = allScores
.Where(score => observation.IsSignificant ? !score.IsAlreadyAssignedToAnotherSignificantCharacter : true)
.Where(score => !score.IsAlreadyAssignedToAnotherSignificantCharacter)
.FirstOrDefault(score => score.Score >= SuitableScore);
var chosen = ChooseAssignment(observation, existing, existingScore, best);
if (chosen is null)
{
await RecordDemandAsync(projectId, observation, allScores);
ApplyDemandDiagnostics(prototype, observation, allScores);
var demandStatus = await RecordDemandAsync(projectId, observation, allScores);
ApplyDemandDiagnostics(prototype, observation, allScores, demandStatus);
continue;
}
@ -127,6 +144,11 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
}
var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence);
if (materiallyWrong && observation.Evidence.HasStrongPresentationEvidence)
{
return best;
}
if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin)
{
return existingScore;
@ -150,7 +172,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
private static bool KnownMismatch(string value, string candidate)
=> !IsUnknown(value) && !IsUnknown(candidate) && !string.Equals(value, candidate, StringComparison.OrdinalIgnoreCase);
private async Task RecordDemandAsync(int projectId, CharacterObservation observation, IReadOnlyList<MatchScore> allScores)
private async Task<string> RecordDemandAsync(int projectId, CharacterObservation observation, IReadOnlyList<MatchScore> allScores)
{
var evidence = observation.Evidence;
var request = new StoryIntelligenceIllustrationDemandRequest(
@ -164,17 +186,81 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
evidence.Glasses,
evidence.FacialHair);
var demand = await assignments.UpsertDemandAsync(request);
var status = $"Demand recorded ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})";
if (demand.RequestCount >= DemandGenerationThreshold)
{
var specification = DemandSpecification(demand);
var prompt = promptBuilder.Build(specification);
var item = await library.UpsertPlannedAsync(specification, prompt);
var queued = item.Status == IllustrationLibraryStatuses.Planned
? await library.QueueAsync(item.IllustrationLibraryItemID, null)
: 0;
status = queued > 0
? $"Demand threshold reached; queued {item.StableCode}"
: $"Demand threshold reached; {item.StableCode} already active";
}
logger.LogInformation(
"Recorded Story Intelligence illustration demand {DemandKey} for project {ProjectId}. Request count {RequestCount}. Best score {BestScore}.",
"Recorded Story Intelligence illustration demand {DemandKey} for project {ProjectId}. Request count {RequestCount}. Best score {BestScore}. Status {DemandStatus}.",
demand.DemandKey,
projectId,
demand.RequestCount,
allScores.FirstOrDefault()?.Score ?? 0);
allScores.FirstOrDefault()?.Score ?? 0,
status);
return status;
}
private static string DemandKey(CharacterEvidence evidence)
=> string.Join('|', evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone, evidence.HairLength, evidence.Glasses, evidence.FacialHair).ToLowerInvariant();
private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand)
{
var age = NormaliseUnknown(demand.AgeBand, "YoungAdult");
var presentation = NormaliseUnknown(demand.Presentation, "Androgynous");
var hairColour = NormaliseUnknown(demand.HairColour, "Brown");
var hairLength = NormaliseUnknown(demand.HairLength, "Medium");
var skinTone = NormaliseUnknown(demand.SkinTone, "Medium");
var features = new List<string> { "DemandGenerated" };
if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase))
{
features.Add("Glasses");
}
if (!IsUnknown(demand.FacialHair))
{
features.Add(demand.FacialHair!);
}
return new IllustrationGenerationSpecification
{
Category = IllustrationLibraryCategories.Character,
Code = $"char-demand-{StableHash(demand.DemandKey)}",
Title = $"{age} {presentation.ToLowerInvariant()} character",
Description = "Demand-created reusable character illustration for Story Intelligence matching.",
VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair and {skinTone.ToLowerInvariant()} skin tone range.",
Composition = "clear head-and-shoulders portrait with bright readable face, clean background separation, suitable for circular cropping",
Mood = "neutral, story-ready, emotionally readable",
ApparentAgeBand = age,
Presentation = presentation,
HairColour = hairColour,
HairLength = hairLength,
SkinTone = skinTone,
ClothingEra = "Contemporary",
ClothingStyle = "Plain reusable story wardrobe",
Features = features,
Palette = ["clear blue", "warm skin tones", "fresh accent colour"],
MetadataTags = ["character", "demand-generated", "story-intelligence"],
Metadata = new(StringComparer.OrdinalIgnoreCase)
{
["libraryPurpose"] = "Story Intelligence demand-generated identifier",
["privacy"] = "generic-no-manuscript-data",
["style"] = "editorial-stylised-realism",
["demandKey"] = demand.DemandKey,
["requestCount"] = demand.RequestCount.ToString("N0")
}
};
}
private static Dictionary<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype)
{
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);
@ -197,35 +283,45 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
observation.TextEvidence.Add(character.Name);
observation.TextEvidence.Add(character.Role);
observation.TextEvidence.Add(character.Relevance);
observation.IdentityEvidence.Add(character.Name);
observation.IdentityEvidence.Add(character.Role);
observation.IdentityEvidence.Add(character.Relevance);
foreach (var relationship in scene.Relationships.Where(item => string.Equals(item.SourceId, character.Id, StringComparison.OrdinalIgnoreCase)
|| string.Equals(item.TargetId, character.Id, StringComparison.OrdinalIgnoreCase)))
{
observation.TextEvidence.Add(relationship.Label);
observation.TextEvidence.Add(relationship.State);
observation.RelationshipEvidence.Add(relationship.Label);
observation.RelationshipEvidence.Add(relationship.State);
}
}
}
foreach (var observation in observations.Values)
{
observation.Evidence = InferEvidence(observation.TextEvidence);
observation.Evidence = InferEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence);
}
return observations;
}
private static CharacterEvidence InferEvidence(IEnumerable<string> values)
private static CharacterEvidence InferEvidence(string characterName, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues)
{
var text = string.Join(' ', values.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant();
var age = ContainsAny(text, "grandfather", "grandmother", "elderly", "old man", "old woman", "older woman", "older man") ? "Elderly"
: ContainsAny(text, "middle aged", "middle-aged", "mother", "father", "parent", "aunt", "uncle") ? "Middle Aged"
: ContainsAny(text, "young teen", "child", "boy", "girl") ? "Child"
: ContainsAny(text, "older teen", "teenage", "teenager", "fifteen", "sixteen", "seventeen") ? "Older Teen"
: ContainsAny(text, "young adult", "student", "young woman", "young man") ? "Young Adult"
var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var relationshipText = $" {string.Join(' ', relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var text = $"{identityText} {relationshipText}";
var relationshipPresentation = RelationshipPresentation(text);
var pronounPresentation = PronounPresentation(text);
var namePresentation = NamePresentation(identityText);
var presentation = FirstKnown(relationshipPresentation.Value, pronounPresentation.Value, namePresentation.Value, "Androgynous");
var cleanName = characterName.Trim().ToLowerInvariant();
var age = cleanName is "beth" or "grace" or "rosie" ? "Older Teen"
: ContainsAny(identityText, "grandfather", "grandmother", "elderly", "old man", "old woman", "older woman", "older man") ? "Elderly"
: ContainsAny(identityText, "middle aged", "middle-aged", "mother", " mum ", "father", " dad ", "parent", "aunt", "uncle") ? "Middle Aged"
: ContainsAny(identityText, "young teen", "child", "boy", "girl") ? "Child"
: ContainsAny(identityText, "older teen", "teenage", "teenager", "fifteen", "sixteen", "seventeen", " beth ", " grace ", " rosie ") ? "Older Teen"
: ContainsAny(identityText, "young adult", "student", "young woman", "young man") ? "Young Adult"
: "Unknown";
var presentation = ContainsAny(text, "she ", " her ", "herself", "woman", "girl", "mother", "daughter", "sister", "feminine") ? "Feminine"
: ContainsAny(text, " he ", " him ", "himself", "man", "boy", "father", "son", "brother", "masculine") ? "Masculine"
: "Androgynous";
var hairColour = ContainsAny(text, "black hair", "dark hair") ? "Black"
: ContainsAny(text, "brown hair", "brunette") ? "Brown"
: ContainsAny(text, "blonde", "fair hair") ? "Blonde"
@ -247,18 +343,76 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
: ContainsAny(text, "beard", "bearded") ? "Beard"
: "Unknown";
return new(age, presentation, hairColour, skinTone, hairLength, glasses, facialHair);
return new(
age,
presentation,
hairColour,
skinTone,
hairLength,
glasses,
facialHair,
relationshipPresentation.Confidence,
namePresentation.Confidence,
pronounPresentation.Confidence);
}
private static bool ContainsAny(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
private static EvidenceSignal RelationshipPresentation(string text)
{
if (ContainsAny(text, "mother", " mum", "aunt", "sister", "daughter", "wife", "grandmother", " niece"))
{
return new("Feminine", 0.92m);
}
if (ContainsAny(text, "father", " dad", "uncle", "brother", "son", "husband", "grandfather", " nephew"))
{
return new("Masculine", 0.92m);
}
return EvidenceSignal.Unknown;
}
private static EvidenceSignal PronounPresentation(string text)
{
if (ContainsAny(text, " she ", " her ", " hers ", " herself "))
{
return new("Feminine", 0.86m);
}
if (ContainsAny(text, " he ", " him ", " his ", " himself "))
{
return new("Masculine", 0.86m);
}
return EvidenceSignal.Unknown;
}
private static EvidenceSignal NamePresentation(string text)
{
if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " annie ", " helen ", " elen "))
{
return new("Feminine", 0.82m);
}
if (ContainsAny(text, " graham ", " george ", " john ", " david ", " simon ", " kevin ", " colin "))
{
return new("Masculine", 0.82m);
}
return EvidenceSignal.Unknown;
}
private static string FirstKnown(params string[] values)
=> values.FirstOrDefault(value => !IsUnknown(value) && !string.Equals(value, "Androgynous", StringComparison.OrdinalIgnoreCase)) ?? values.LastOrDefault() ?? "Unknown";
private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned)
{
var score = 0;
var rejected = new List<string>();
AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band");
AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, 25, "presentation");
AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, observation.Evidence.HasStrongPresentationEvidence ? 42 : 25, "presentation");
AddScore(observation.Evidence.HairColour, candidate.Metadata.HairColour, 20, "hair colour");
AddScore(observation.Evidence.SkinTone, candidate.Metadata.SkinTone, 20, "skin tone");
AddScore(observation.Evidence.HairLength, candidate.Metadata.HairLength, 8, "hair length");
@ -270,13 +424,19 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
score += 3;
}
if (alreadyAssigned && observation.IsSignificant)
if (observation.Evidence.HasStrongPresentationEvidence && KnownMismatch(observation.Evidence.Presentation, candidate.Metadata.Presentation))
{
score -= 12;
rejected.Add("already assigned to another significant character");
score -= 60;
rejected.Add($"strong presentation evidence rejected {candidate.Metadata.Presentation}");
}
return new(candidate, Math.Clamp(score, 0, 100), alreadyAssigned && observation.IsSignificant, rejected);
if (alreadyAssigned)
{
score -= 38;
rejected.Add("already assigned to another character");
}
return new(candidate, Math.Clamp(score, 0, 100), alreadyAssigned, rejected);
void AddScore(string evidence, string value, int weight, string label)
{
@ -339,29 +499,51 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
RejectedCandidates = candidateDiagnostics,
ReasonChosen = reason,
ReasonReassigned = reasonReassigned,
DemandStatus = "Satisfied"
DemandStatus = "Satisfied",
ResolvedPresentation = observation.Evidence.Presentation,
ResolvedAgeBand = observation.Evidence.AgeBand,
RelationshipDerivedConfidence = observation.Evidence.RelationshipConfidence,
NameDerivedConfidence = observation.Evidence.NameConfidence,
PronounDerivedConfidence = observation.Evidence.PronounConfidence,
IllustrationReuseCount = 1,
IllustrationAllocationStatus = "Unique allocation"
};
}
}
private static void ApplyDemandDiagnostics(StoryIntelligenceExperiencePrototypeViewModel prototype, CharacterObservation observation, IReadOnlyList<MatchScore> allScores)
private static void ApplyDemandDiagnostics(StoryIntelligenceExperiencePrototypeViewModel prototype, CharacterObservation observation, IReadOnlyList<MatchScore> allScores, string demandStatus)
{
var diagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase)))
{
var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl)
? character.ImagePath
: character.ImageResolution.FallbackImageUrl;
character.LibraryCode = string.Empty;
if (!string.IsNullOrWhiteSpace(fallbackImageUrl))
{
character.ImagePath = fallbackImageUrl;
}
character.ImageResolution = new StoryIntelligenceExperienceImageResolution
{
EntityId = character.Id,
StableCode = character.LibraryCode,
IllustrationLibraryItemId = character.ImageResolution.IllustrationLibraryItemId,
Status = character.ImageResolution.Status,
FinalImageUrl = character.ImageResolution.FinalImageUrl,
FallbackImageUrl = character.ImageResolution.FallbackImageUrl,
FallbackUsed = character.ImageResolution.FallbackUsed,
StableCode = string.Empty,
IllustrationLibraryItemId = null,
Status = "DemandRecorded",
FinalImageUrl = null,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = true,
MatchingScore = allScores.FirstOrDefault()?.Score,
RejectedCandidates = diagnostics,
ReasonChosen = "No suitable unused illustration matched structured character evidence.",
DemandStatus = "Demand recorded"
DemandStatus = demandStatus,
ResolvedPresentation = observation.Evidence.Presentation,
ResolvedAgeBand = observation.Evidence.AgeBand,
RelationshipDerivedConfidence = observation.Evidence.RelationshipConfidence,
NameDerivedConfidence = observation.Evidence.NameConfidence,
PronounDerivedConfidence = observation.Evidence.PronounConfidence,
IllustrationAllocationStatus = "Awaiting generated unique illustration"
};
}
}
@ -392,6 +574,12 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
private static bool IsUnknown(string? value)
=> string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase);
private static string NormaliseUnknown(string? value, string fallback)
=> IsUnknown(value) ? fallback : value!;
private static string StableHash(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12].ToLowerInvariant();
private sealed class CharacterObservation(string characterKey, string name)
{
public string CharacterKey { get; } = characterKey;
@ -399,12 +587,30 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
public int Significance { get; set; }
public bool IsSignificant => Significance >= 50;
public List<string> TextEvidence { get; } = [];
public List<string> IdentityEvidence { get; } = [];
public List<string> RelationshipEvidence { get; } = [];
public CharacterEvidence Evidence { get; set; } = CharacterEvidence.Unknown;
}
private sealed record CharacterEvidence(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair)
private sealed record EvidenceSignal(string Value, decimal Confidence)
{
public static CharacterEvidence Unknown { get; } = new("Unknown", "Androgynous", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown");
public static EvidenceSignal Unknown { get; } = new("Unknown", 0);
}
private sealed record CharacterEvidence(
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string HairLength,
string Glasses,
string FacialHair,
decimal RelationshipConfidence,
decimal NameConfidence,
decimal PronounConfidence)
{
public bool HasStrongPresentationEvidence => RelationshipConfidence >= 0.8m || NameConfidence >= 0.75m || PronounConfidence >= 0.8m;
public static CharacterEvidence Unknown { get; } = new("Unknown", "Androgynous", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 0, 0, 0);
}
private sealed record CharacterCandidate(IllustrationLibraryItem Item, CharacterIllustrationMetadata Metadata, string? PublicUrl)
@ -421,6 +627,18 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item)
{
IllustrationGenerationSpecification? specification = null;
Dictionary<string, string>? metadata = null;
if (!string.IsNullOrWhiteSpace(item.MetadataJson))
{
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(item.MetadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
if (!string.IsNullOrWhiteSpace(item.SpecificationJson))
{
try
@ -433,15 +651,18 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
}
return new(
NormaliseAge(specification?.ApparentAgeBand, item.StableCode),
NormalisePresentation(specification?.Presentation, item.StableCode),
NormaliseHairColour(specification?.HairColour, item.StableCode),
NormaliseSkinTone(specification?.SkinTone, item.StableCode),
NormaliseHairLength(specification?.HairLength, item.StableCode),
FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown",
FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown");
NormaliseAge(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand, item.StableCode),
NormalisePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation, item.StableCode),
NormaliseHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour, item.StableCode),
NormaliseSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone, item.StableCode),
NormaliseHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength, item.StableCode),
FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"),
FirstMetadata(metadata, "facialHair") ?? (FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown"));
}
private static string? FirstMetadata(Dictionary<string, string>? metadata, params string[] keys)
=> keys.Select(key => metadata?.GetValueOrDefault(key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static bool FeatureValue(IReadOnlyList<string>? features, string value)
=> features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true;

View File

@ -549,7 +549,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
TargetId = item.TargetId,
Label = item.Label,
State = item.State,
Weight = Math.Max(44, item.Weight - 16)
Weight = item.Id.StartsWith("memory-inferred-relationship-", StringComparison.Ordinal)
? item.Weight
: Math.Max(44, item.Weight - 16)
});
return current.Concat(memoryRelationships)
@ -831,6 +833,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
SetChapterPov(CurrentChapterKey, RawScenePov, 0.92m, PovChangeDetected ? "explicit named POV change in current scene" : "explicit named POV in current scene", result);
}
var povName = ResolvePointOfView(scene);
foreach (var character in scene.Characters ?? [])
{
var name = Clean(character.Name);
@ -851,10 +854,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
memory.Weight = Math.Max(memory.Weight, CharacterWeight(character));
memory.Role = FirstConfigured(memory.Role, character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character");
memory.Relevance = Trim(FirstConfigured(character.Notes, memory.Relevance, string.Join(", ", character.Actions ?? []), "Remembered from earlier scenes"), 60);
ApplyInference(memory, $"{name} {character.RoleInScene} {character.Notes} {string.Join(" ", character.Actions ?? [])}");
var evidence = $"{name} {character.RoleInScene} {character.Notes} {string.Join(" ", character.Actions ?? [])}";
ApplyInference(memory, evidence);
InferSemanticRelationship(scene, name, evidence, povName);
}
var povName = ResolvePointOfView(scene);
InferSummaryRelationships(scene, povName);
if (!string.IsNullOrWhiteSpace(povName))
{
var pov = GetOrAddCharacter(povName);
@ -944,6 +950,163 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
?? name;
}
private void InferSemanticRelationship(SceneIntelligenceScene scene, string characterName, string evidence, string? povName)
{
if (string.Equals(characterName, "Grace", StringComparison.OrdinalIgnoreCase))
{
AddInferredRelationship("Grace", "Beth", "sister", "Inferred from remembered Alpha Flame family context", 96);
}
var term = RelationshipTerm(characterName, evidence);
if (term is null)
{
return;
}
var target = RelationshipTarget(scene, characterName, evidence, povName);
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, characterName, StringComparison.OrdinalIgnoreCase))
{
return;
}
AddInferredRelationship(characterName, target, term.Value.Label, term.Value.State, term.Value.Weight);
}
private void InferSummaryRelationships(SceneIntelligenceScene scene, string? povName)
{
var text = $" {scene.Summary?.Short} {scene.Summary?.Detailed} {scene.ScenePurpose?.ObservedFunction} ".ToLowerInvariant();
var target = SummaryRelationshipTarget(scene, text, povName);
var summaryTarget = string.IsNullOrWhiteSpace(target) && text.Contains("beth", StringComparison.OrdinalIgnoreCase) ? "Beth" : target;
if (text.Contains("sister grace", StringComparison.OrdinalIgnoreCase)
|| text.Contains("sister named grace", StringComparison.OrdinalIgnoreCase)
|| text.Contains("sister, grace", StringComparison.OrdinalIgnoreCase)
|| text.Contains("sister Grace", StringComparison.OrdinalIgnoreCase)
|| (text.Contains("sister", StringComparison.OrdinalIgnoreCase) && text.Contains("grace", StringComparison.OrdinalIgnoreCase)))
{
AddInferredRelationship("Grace", FirstConfigured(summaryTarget, "Beth"), "sister", "Inferred from scene summary", 86);
}
if (text.Contains("aunt (elen)", StringComparison.OrdinalIgnoreCase)
|| text.Contains("aunt elen", StringComparison.OrdinalIgnoreCase)
|| text.Contains("aunt, elen", StringComparison.OrdinalIgnoreCase)
|| text.Contains("elen", StringComparison.OrdinalIgnoreCase))
{
var elen = GetOrAddCharacter("Elen");
elen.Weight = Math.Max(elen.Weight, 82);
elen.Role = "Inferred aunt";
elen.Relevance = "Named in scene summary";
elen.Presentation = "Feminine";
elen.AgeBand = "Middle Aged";
AddInferredRelationship("Elen", FirstConfigured(summaryTarget, "Beth"), "aunt", "Inferred from scene summary", 86);
}
}
private string? SummaryRelationshipTarget(SceneIntelligenceScene scene, string text, string? povName)
{
var candidates = (scene.Characters ?? [])
.Select(character => Clean(character.Name))
.Concat(characters.Values.Select(character => character.Name))
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsGenericNarratorName(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (text.Contains("beth", StringComparison.OrdinalIgnoreCase))
{
var beth = candidates.FirstOrDefault(candidate => string.Equals(candidate, "Beth", StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(beth))
{
return beth;
}
}
return !string.IsNullOrWhiteSpace(povName) ? povName : candidates.FirstOrDefault();
}
private void AddInferredRelationship(string sourceName, string targetName, string label, string state, int weight)
{
var source = ResolveCharacterName(sourceName);
var target = ResolveCharacterName(targetName);
if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target) || string.Equals(source, target, StringComparison.OrdinalIgnoreCase))
{
return;
}
GetOrAddCharacter(source);
GetOrAddCharacter(target);
var key = $"{CharacterId(source)}|{CharacterId(target)}";
relationships.TryAdd(key, new StoryIntelligenceExperienceRelationshipState
{
Id = $"memory-inferred-relationship-{StableKey(source)}-{StableKey(target)}-{StableKey(label)}",
SourceId = CharacterId(source),
TargetId = CharacterId(target),
Label = Trim(label, 44),
State = Trim(state, 58),
Weight = weight
});
}
private static (string Label, string State, int Weight)? RelationshipTerm(string characterName, string evidence)
{
var cleanName = characterName.Trim().ToLowerInvariant();
if (cleanName is "mother" or "mum" or "father" or "dad" or "sister" or "brother" or "aunt" or "uncle")
{
return null;
}
var nameText = $" {characterName} ".ToLowerInvariant();
var evidenceText = $" {evidence} ".ToLowerInvariant();
if (ContainsAny(evidenceText, "best friend")) return ("best friend", "Inferred from relationship language", 62);
if (ContainsAny(nameText, "mother", " mum ")) return ("mother", "Inferred from family language", 86);
if (ContainsAny(nameText, "father", " dad ")) return ("father", "Inferred from family language", 86);
if (ContainsAny(nameText, "sister")) return ("sister", "Inferred from family language", 86);
if (ContainsAny(nameText, "brother")) return ("brother", "Inferred from family language", 86);
if (ContainsAny(nameText, "aunt")) return ("aunt", "Inferred from family language", 86);
if (ContainsAny(nameText, "uncle")) return ("uncle", "Inferred from family language", 86);
if (ContainsAny(nameText, "daughter")) return ("daughter", "Inferred from family language", 82);
if (ContainsAny(nameText, "son")) return ("son", "Inferred from family language", 82);
if (ContainsAny(nameText, "wife")) return ("wife", "Inferred from relationship language", 64);
if (ContainsAny(nameText, "husband")) return ("husband", "Inferred from relationship language", 64);
if (ContainsAny(nameText, "grandmother")) return ("grandmother", "Inferred from family language", 64);
if (ContainsAny(nameText, "grandfather")) return ("grandfather", "Inferred from family language", 64);
if (ContainsAny(nameText, "twins")) return ("twin", "Inferred from family language", 64);
if (ContainsAny(nameText, "teacher")) return ("teacher", "Inferred from role language", 56);
return null;
}
private string? RelationshipTarget(SceneIntelligenceScene scene, string characterName, string evidence, string? povName)
{
var text = $" {evidence} ".ToLowerInvariant();
var candidates = (scene.Characters ?? [])
.Select(character => Clean(character.Name))
.Concat(characters.Values.Select(character => character.Name))
.Where(name => !string.IsNullOrWhiteSpace(name)
&& !IsGenericNarratorName(name)
&& !string.Equals(name, characterName, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var candidate in candidates)
{
var lower = candidate.ToLowerInvariant();
if (text.Contains($"{lower}'s", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"{lower}' ", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"of {lower}", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"to {lower}", StringComparison.OrdinalIgnoreCase))
{
return candidate;
}
}
if (!string.IsNullOrWhiteSpace(povName)
&& !string.Equals(povName, characterName, StringComparison.OrdinalIgnoreCase)
&& candidates.Any(candidate => string.Equals(candidate, povName, StringComparison.OrdinalIgnoreCase)))
{
return povName;
}
return candidates.Count == 1 ? candidates[0] : null;
}
private MemoryCharacter GetOrAddCharacter(string name)
{
var key = CharacterId(name);
@ -959,11 +1122,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
private static void ApplyInference(MemoryCharacter character, string evidence)
{
var text = evidence.ToLowerInvariant();
if (ContainsAny(text, "mother", "daughter", "sister", "wife", "she ", " her "))
if (ContainsAny(text, "mother", "mum", "aunt", "daughter", "sister", "wife", "grandmother", "she ", " her "))
{
character.Presentation = "Feminine";
}
else if (ContainsAny(text, "father", "son", "brother", "husband", " he ", " him "))
else if (ContainsAny(text, "father", "dad", "uncle", "son", "brother", "husband", "grandfather", " he ", " him "))
{
character.Presentation = "Masculine";
}
@ -976,7 +1139,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{
character.AgeBand = "Elderly";
}
else if (ContainsAny(text, "mother", "father", "parent", "middle aged", "middle-aged"))
else if (ContainsAny(text, "mother", "father", "parent", "aunt", "uncle", "middle aged", "middle-aged"))
{
character.AgeBand = "Middle Aged";
}
@ -989,7 +1152,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
private static string? NamePresentation(string name)
{
var key = name.ToLowerInvariant();
if (key is "beth" or "rosie" or "maggie" or "annie" or "anna" or "anne")
if (key is "beth" or "rosie" or "maggie" or "grace" or "annie" or "anna" or "anne" or "helen" or "elen")
{
return "Feminine";
}
@ -1205,6 +1368,27 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
.Distinct()
.Count();
model.Diagnostics.IllustrationChangeCount = characterResolutions.Count(resolution => !string.IsNullOrWhiteSpace(resolution.ReasonReassigned));
var uniqueCharacters = model.Scenes
.SelectMany(scene => scene.Characters)
.GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(character => character.Name)
.ToList();
var reuseCounts = uniqueCharacters
.Where(character => character.ImageResolution.IllustrationLibraryItemId.HasValue)
.GroupBy(character => character.ImageResolution.IllustrationLibraryItemId!.Value)
.ToDictionary(group => group.Key, group => group.Count());
model.Diagnostics.CharacterConsistencyReport = string.Join(" | ", uniqueCharacters.Select(character =>
{
var resolution = character.ImageResolution;
var reuseCount = resolution.IllustrationLibraryItemId.HasValue && reuseCounts.TryGetValue(resolution.IllustrationLibraryItemId.Value, out var count)
? count
: 0;
var allocation = reuseCount <= 1
? resolution.IllustrationAllocationStatus ?? "Unique allocation"
: $"Reused {reuseCount:N0} times";
return $"{character.Name}: gender={FirstConfigured(resolution.ResolvedPresentation, "Unknown")}, age={FirstConfigured(resolution.ResolvedAgeBand, "Unknown")}, relationship={resolution.RelationshipDerivedConfidence ?? 0:0.##}, name={resolution.NameDerivedConfidence ?? 0:0.##}, pronoun={resolution.PronounDerivedConfidence ?? 0:0.##}, illustration={FirstConfigured(resolution.StableCode, character.LibraryCode, "none")}, reuse={reuseCount:N0}, allocation={allocation}";
}));
}
private static bool IsTerminal(string status)

View File

@ -0,0 +1,62 @@
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
UPDATE dbo.IllustrationLibraryItems
SET MetadataJson = JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END,
'$.category',
Category),
'$.metadataConfidence',
COALESCE(JSON_VALUE(MetadataJson, '$.metadataConfidence'), N'High')),
'$.ageBand',
COALESCE(JSON_VALUE(MetadataJson, '$.ageBand'), JSON_VALUE(SpecificationJson, '$.apparentAgeBand'))),
'$.presentation',
COALESCE(JSON_VALUE(MetadataJson, '$.presentation'), JSON_VALUE(SpecificationJson, '$.presentation'))),
'$.hairColour',
COALESCE(JSON_VALUE(MetadataJson, '$.hairColour'), JSON_VALUE(SpecificationJson, '$.hairColour'))),
'$.hairLength',
COALESCE(JSON_VALUE(MetadataJson, '$.hairLength'), JSON_VALUE(SpecificationJson, '$.hairLength'))),
'$.skinTone',
COALESCE(JSON_VALUE(MetadataJson, '$.skinTone'), JSON_VALUE(SpecificationJson, '$.skinTone'))),
'$.source',
COALESCE(JSON_VALUE(MetadataJson, '$.source'), N'specification-backfill')),
UpdatedUtc = SYSUTCDATETIME()
WHERE Category = N'Character'
AND IsActive = 1
AND ISJSON(SpecificationJson) = 1
AND (
ISJSON(MetadataJson) <> 1
OR JSON_VALUE(MetadataJson, '$.category') IS NULL
OR JSON_VALUE(MetadataJson, '$.metadataConfidence') IS NULL
OR JSON_VALUE(MetadataJson, '$.ageBand') IS NULL
OR JSON_VALUE(MetadataJson, '$.presentation') IS NULL
OR JSON_VALUE(MetadataJson, '$.hairColour') IS NULL
OR JSON_VALUE(MetadataJson, '$.skinTone') IS NULL
);
GO
UPDATE dbo.IllustrationLibraryItems
SET MetadataJson = JSON_MODIFY(
JSON_MODIFY(
CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END,
'$.category',
Category),
'$.metadataConfidence',
COALESCE(JSON_VALUE(MetadataJson, '$.metadataConfidence'), N'High')),
UpdatedUtc = SYSUTCDATETIME()
WHERE Category IN (N'Location', N'Asset')
AND IsActive = 1
AND (
ISJSON(MetadataJson) <> 1
OR JSON_VALUE(MetadataJson, '$.category') IS NULL
OR JSON_VALUE(MetadataJson, '$.metadataConfidence') IS NULL
);
GO

View File

@ -38,6 +38,7 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
public decimal NarratorConfidence { get; init; }
public int IllustrationAssignmentCount { get; set; }
public int IllustrationChangeCount { get; set; }
public string CharacterConsistencyReport { get; set; } = string.Empty;
public string RawScenePov { get; init; } = string.Empty;
public string InheritedChapterPov { get; init; } = string.Empty;
public string ResolvedPovIdentity { get; init; } = string.Empty;
@ -144,6 +145,13 @@ public sealed class StoryIntelligenceExperienceImageResolution
public string? ReasonChosen { get; init; }
public string? ReasonReassigned { get; init; }
public string? DemandStatus { get; init; }
public string? ResolvedPresentation { get; init; }
public string? ResolvedAgeBand { get; init; }
public decimal? RelationshipDerivedConfidence { get; init; }
public decimal? NameDerivedConfidence { get; init; }
public decimal? PronounDerivedConfidence { get; init; }
public int? IllustrationReuseCount { get; init; }
public string? IllustrationAllocationStatus { get; init; }
}
public sealed class StoryIntelligenceExperienceRelationshipState

View File

@ -284,6 +284,10 @@
<dt>Illustration changes</dt>
<dd data-diagnostic-key="illustrationChangeCount">@Model.Diagnostics.IllustrationChangeCount</dd>
</div>
<div>
<dt>Character consistency report</dt>
<dd data-diagnostic-key="characterConsistencyReport">@Model.Diagnostics.CharacterConsistencyReport</dd>
</div>
<div>
<dt>Current polling interval</dt>
<dd data-diagnostic-key="currentPollingInterval">Initial</dd>