Phase 21J.2 - Character Identity and Illustration Matching
This commit is contained in:
parent
b513bd7df0
commit
69e4ac8201
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
62
PlotLine/Sql/139_Phase21J2_IllustrationMetadataBackfill.sql
Normal file
62
PlotLine/Sql/139_Phase21J2_IllustrationMetadataBackfill.sql
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user