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); 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)) if (string.Equals(specification.Category, IllustrationLibraryCategories.Character, StringComparison.OrdinalIgnoreCase))
{ {
AddMetadata("ageBand", specification.ApparentAgeBand); AddMetadata("ageBand", specification.ApparentAgeBand);

View File

@ -1,5 +1,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Security.Cryptography;
using System.Text;
using PlotLine.Data; using PlotLine.Data;
using PlotLine.Models; using PlotLine.Models;
using PlotLine.ViewModels; using PlotLine.ViewModels;
@ -14,11 +16,13 @@ public interface IStoryIntelligenceIllustrationMatchingService
public sealed class StoryIntelligenceIllustrationMatchingService( public sealed class StoryIntelligenceIllustrationMatchingService(
IIllustrationLibraryRepository library, IIllustrationLibraryRepository library,
IStoryIntelligenceIllustrationMatchingRepository assignments, IStoryIntelligenceIllustrationMatchingRepository assignments,
IIllustrationPromptBuilder promptBuilder,
IUploadStorageService uploadStorage, IUploadStorageService uploadStorage,
ILogger<StoryIntelligenceIllustrationMatchingService> logger) : IStoryIntelligenceIllustrationMatchingService ILogger<StoryIntelligenceIllustrationMatchingService> logger) : IStoryIntelligenceIllustrationMatchingService
{ {
private const int SuitableScore = 48; private const int SuitableScore = 48;
private const int ReassignmentMargin = 24; private const int ReassignmentMargin = 24;
private const int DemandGenerationThreshold = 3;
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@ -53,12 +57,25 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
.Where(item => item.IllustrationLibraryItemID.HasValue) .Where(item => item.IllustrationLibraryItemID.HasValue)
.Select(item => item.IllustrationLibraryItemID!.Value) .Select(item => item.IllustrationLibraryItemID!.Value)
.ToHashSet(); .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); var observations = BuildCharacterObservations(prototype);
foreach (var observation in observations.Values.OrderByDescending(item => item.Significance).ThenBy(item => item.Name)) foreach (var observation in observations.Values.OrderByDescending(item => item.Significance).ThenBy(item => item.Name))
{ {
var existing = existingAssignments.GetValueOrDefault(observation.CharacterKey); var existing = existingAssignments.GetValueOrDefault(observation.CharacterKey);
var ownExistingItemId = existing?.IllustrationLibraryItemID; var ownExistingItemId = existing?.IllustrationLibraryItemID;
if (ownExistingItemId.HasValue && duplicateExistingItemIds.Contains(ownExistingItemId.Value) && !retainedExistingItemIds.Add(ownExistingItemId.Value))
{
ownExistingItemId = null;
existing = null;
}
var allScores = catalogue var allScores = catalogue
.Select(candidate => Score( .Select(candidate => Score(
candidate, candidate,
@ -75,14 +92,14 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId) ? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId)
: null; : null;
var best = allScores var best = allScores
.Where(score => observation.IsSignificant ? !score.IsAlreadyAssignedToAnotherSignificantCharacter : true) .Where(score => !score.IsAlreadyAssignedToAnotherSignificantCharacter)
.FirstOrDefault(score => score.Score >= SuitableScore); .FirstOrDefault(score => score.Score >= SuitableScore);
var chosen = ChooseAssignment(observation, existing, existingScore, best); var chosen = ChooseAssignment(observation, existing, existingScore, best);
if (chosen is null) if (chosen is null)
{ {
await RecordDemandAsync(projectId, observation, allScores); var demandStatus = await RecordDemandAsync(projectId, observation, allScores);
ApplyDemandDiagnostics(prototype, observation, allScores); ApplyDemandDiagnostics(prototype, observation, allScores, demandStatus);
continue; continue;
} }
@ -127,6 +144,11 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
} }
var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence); var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence);
if (materiallyWrong && observation.Evidence.HasStrongPresentationEvidence)
{
return best;
}
if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin) if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin)
{ {
return existingScore; return existingScore;
@ -150,7 +172,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
private static bool KnownMismatch(string value, string candidate) private static bool KnownMismatch(string value, string candidate)
=> !IsUnknown(value) && !IsUnknown(candidate) && !string.Equals(value, candidate, StringComparison.OrdinalIgnoreCase); => !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 evidence = observation.Evidence;
var request = new StoryIntelligenceIllustrationDemandRequest( var request = new StoryIntelligenceIllustrationDemandRequest(
@ -164,17 +186,81 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
evidence.Glasses, evidence.Glasses,
evidence.FacialHair); evidence.FacialHair);
var demand = await assignments.UpsertDemandAsync(request); 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( 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, demand.DemandKey,
projectId, projectId,
demand.RequestCount, demand.RequestCount,
allScores.FirstOrDefault()?.Score ?? 0); allScores.FirstOrDefault()?.Score ?? 0,
status);
return status;
} }
private static string DemandKey(CharacterEvidence evidence) private static string DemandKey(CharacterEvidence evidence)
=> string.Join('|', evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone, evidence.HairLength, evidence.Glasses, evidence.FacialHair).ToLowerInvariant(); => 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) private static Dictionary<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype)
{ {
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase); 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.Name);
observation.TextEvidence.Add(character.Role); observation.TextEvidence.Add(character.Role);
observation.TextEvidence.Add(character.Relevance); 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) foreach (var relationship in scene.Relationships.Where(item => string.Equals(item.SourceId, character.Id, StringComparison.OrdinalIgnoreCase)
|| string.Equals(item.TargetId, character.Id, StringComparison.OrdinalIgnoreCase))) || string.Equals(item.TargetId, character.Id, StringComparison.OrdinalIgnoreCase)))
{ {
observation.TextEvidence.Add(relationship.Label); observation.TextEvidence.Add(relationship.Label);
observation.TextEvidence.Add(relationship.State); observation.TextEvidence.Add(relationship.State);
observation.RelationshipEvidence.Add(relationship.Label);
observation.RelationshipEvidence.Add(relationship.State);
} }
} }
} }
foreach (var observation in observations.Values) foreach (var observation in observations.Values)
{ {
observation.Evidence = InferEvidence(observation.TextEvidence); observation.Evidence = InferEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence);
} }
return observations; 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 identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var age = ContainsAny(text, "grandfather", "grandmother", "elderly", "old man", "old woman", "older woman", "older man") ? "Elderly" var relationshipText = $" {string.Join(' ', relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
: ContainsAny(text, "middle aged", "middle-aged", "mother", "father", "parent", "aunt", "uncle") ? "Middle Aged" var text = $"{identityText} {relationshipText}";
: ContainsAny(text, "young teen", "child", "boy", "girl") ? "Child" var relationshipPresentation = RelationshipPresentation(text);
: ContainsAny(text, "older teen", "teenage", "teenager", "fifteen", "sixteen", "seventeen") ? "Older Teen" var pronounPresentation = PronounPresentation(text);
: ContainsAny(text, "young adult", "student", "young woman", "young man") ? "Young Adult" 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"; : "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" var hairColour = ContainsAny(text, "black hair", "dark hair") ? "Black"
: ContainsAny(text, "brown hair", "brunette") ? "Brown" : ContainsAny(text, "brown hair", "brunette") ? "Brown"
: ContainsAny(text, "blonde", "fair hair") ? "Blonde" : ContainsAny(text, "blonde", "fair hair") ? "Blonde"
@ -247,18 +343,76 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
: ContainsAny(text, "beard", "bearded") ? "Beard" : ContainsAny(text, "beard", "bearded") ? "Beard"
: "Unknown"; : "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) private static bool ContainsAny(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase)); => 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) private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned)
{ {
var score = 0; var score = 0;
var rejected = new List<string>(); var rejected = new List<string>();
AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band"); 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.HairColour, candidate.Metadata.HairColour, 20, "hair colour");
AddScore(observation.Evidence.SkinTone, candidate.Metadata.SkinTone, 20, "skin tone"); AddScore(observation.Evidence.SkinTone, candidate.Metadata.SkinTone, 20, "skin tone");
AddScore(observation.Evidence.HairLength, candidate.Metadata.HairLength, 8, "hair length"); AddScore(observation.Evidence.HairLength, candidate.Metadata.HairLength, 8, "hair length");
@ -270,13 +424,19 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
score += 3; score += 3;
} }
if (alreadyAssigned && observation.IsSignificant) if (observation.Evidence.HasStrongPresentationEvidence && KnownMismatch(observation.Evidence.Presentation, candidate.Metadata.Presentation))
{ {
score -= 12; score -= 60;
rejected.Add("already assigned to another significant character"); 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) void AddScore(string evidence, string value, int weight, string label)
{ {
@ -339,29 +499,51 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
RejectedCandidates = candidateDiagnostics, RejectedCandidates = candidateDiagnostics,
ReasonChosen = reason, ReasonChosen = reason,
ReasonReassigned = reasonReassigned, 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); 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))) 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 character.ImageResolution = new StoryIntelligenceExperienceImageResolution
{ {
EntityId = character.Id, EntityId = character.Id,
StableCode = character.LibraryCode, StableCode = string.Empty,
IllustrationLibraryItemId = character.ImageResolution.IllustrationLibraryItemId, IllustrationLibraryItemId = null,
Status = character.ImageResolution.Status, Status = "DemandRecorded",
FinalImageUrl = character.ImageResolution.FinalImageUrl, FinalImageUrl = null,
FallbackImageUrl = character.ImageResolution.FallbackImageUrl, FallbackImageUrl = fallbackImageUrl,
FallbackUsed = character.ImageResolution.FallbackUsed, FallbackUsed = true,
MatchingScore = allScores.FirstOrDefault()?.Score, MatchingScore = allScores.FirstOrDefault()?.Score,
RejectedCandidates = diagnostics, RejectedCandidates = diagnostics,
ReasonChosen = "No suitable unused illustration matched structured character evidence.", 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) private static bool IsUnknown(string? value)
=> string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase); => 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) private sealed class CharacterObservation(string characterKey, string name)
{ {
public string CharacterKey { get; } = characterKey; public string CharacterKey { get; } = characterKey;
@ -399,12 +587,30 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
public int Significance { get; set; } public int Significance { get; set; }
public bool IsSignificant => Significance >= 50; public bool IsSignificant => Significance >= 50;
public List<string> TextEvidence { get; } = []; public List<string> TextEvidence { get; } = [];
public List<string> IdentityEvidence { get; } = [];
public List<string> RelationshipEvidence { get; } = [];
public CharacterEvidence Evidence { get; set; } = CharacterEvidence.Unknown; 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) private sealed record CharacterCandidate(IllustrationLibraryItem Item, CharacterIllustrationMetadata Metadata, string? PublicUrl)
@ -421,6 +627,18 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item) public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item)
{ {
IllustrationGenerationSpecification? specification = null; 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)) if (!string.IsNullOrWhiteSpace(item.SpecificationJson))
{ {
try try
@ -433,15 +651,18 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
} }
return new( return new(
NormaliseAge(specification?.ApparentAgeBand, item.StableCode), NormaliseAge(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand, item.StableCode),
NormalisePresentation(specification?.Presentation, item.StableCode), NormalisePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation, item.StableCode),
NormaliseHairColour(specification?.HairColour, item.StableCode), NormaliseHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour, item.StableCode),
NormaliseSkinTone(specification?.SkinTone, item.StableCode), NormaliseSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone, item.StableCode),
NormaliseHairLength(specification?.HairLength, item.StableCode), NormaliseHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength, item.StableCode),
FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown", FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"),
FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "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) private static bool FeatureValue(IReadOnlyList<string>? features, string value)
=> features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true; => features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true;

View File

@ -549,7 +549,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
TargetId = item.TargetId, TargetId = item.TargetId,
Label = item.Label, Label = item.Label,
State = item.State, 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) 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); 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 ?? []) foreach (var character in scene.Characters ?? [])
{ {
var name = Clean(character.Name); var name = Clean(character.Name);
@ -851,10 +854,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
memory.Weight = Math.Max(memory.Weight, CharacterWeight(character)); memory.Weight = Math.Max(memory.Weight, CharacterWeight(character));
memory.Role = FirstConfigured(memory.Role, character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene 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); 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)) if (!string.IsNullOrWhiteSpace(povName))
{ {
var pov = GetOrAddCharacter(povName); var pov = GetOrAddCharacter(povName);
@ -944,6 +950,163 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
?? name; ?? 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) private MemoryCharacter GetOrAddCharacter(string name)
{ {
var key = CharacterId(name); var key = CharacterId(name);
@ -959,11 +1122,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
private static void ApplyInference(MemoryCharacter character, string evidence) private static void ApplyInference(MemoryCharacter character, string evidence)
{ {
var text = evidence.ToLowerInvariant(); 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"; 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"; character.Presentation = "Masculine";
} }
@ -976,7 +1139,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{ {
character.AgeBand = "Elderly"; 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"; character.AgeBand = "Middle Aged";
} }
@ -989,7 +1152,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
private static string? NamePresentation(string name) private static string? NamePresentation(string name)
{ {
var key = name.ToLowerInvariant(); 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"; return "Feminine";
} }
@ -1205,6 +1368,27 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
.Distinct() .Distinct()
.Count(); .Count();
model.Diagnostics.IllustrationChangeCount = characterResolutions.Count(resolution => !string.IsNullOrWhiteSpace(resolution.ReasonReassigned)); 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) 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 decimal NarratorConfidence { get; init; }
public int IllustrationAssignmentCount { get; set; } public int IllustrationAssignmentCount { get; set; }
public int IllustrationChangeCount { get; set; } public int IllustrationChangeCount { get; set; }
public string CharacterConsistencyReport { get; set; } = string.Empty;
public string RawScenePov { get; init; } = string.Empty; public string RawScenePov { get; init; } = string.Empty;
public string InheritedChapterPov { get; init; } = string.Empty; public string InheritedChapterPov { get; init; } = string.Empty;
public string ResolvedPovIdentity { 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? ReasonChosen { get; init; }
public string? ReasonReassigned { get; init; } public string? ReasonReassigned { get; init; }
public string? DemandStatus { 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 public sealed class StoryIntelligenceExperienceRelationshipState

View File

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