PlotDirector/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs

730 lines
35 KiB
C#

using System.Text.Json;
using System.Text.RegularExpressions;
using System.Security.Cryptography;
using System.Text;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceIllustrationMatchingService
{
Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
}
public sealed class StoryIntelligenceIllustrationMatchingService(
IIllustrationLibraryRepository library,
IStoryIntelligenceIllustrationMatchingRepository assignments,
IIllustrationPromptBuilder promptBuilder,
IUploadStorageService uploadStorage,
ILogger<StoryIntelligenceIllustrationMatchingService> logger) : IStoryIntelligenceIllustrationMatchingService
{
private const int SuitableScore = 48;
private const int ReassignmentMargin = 24;
private const int DemandGenerationThreshold = 3;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public async Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype)
{
if (projectId <= 0 || prototype.Scenes.Count == 0)
{
return;
}
var catalogue = (await library.ListAsync(new IllustrationLibraryFilter { Category = IllustrationLibraryCategories.Character }))
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.Select(CharacterCandidate.From)
.Where(candidate => candidate is not null)
.Cast<CharacterCandidate>()
.Select(candidate => candidate with { PublicUrl = ResolveExistingPublicUploadUrl(candidate.Item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.Item.FilePath) })
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.PublicUrl))
.ToList();
if (catalogue.Count == 0)
{
logger.LogWarning("No generated character illustrations are available for Story Intelligence matching.");
return;
}
var existingAssignments = (await assignments.ListAssignmentsAsync(projectId))
.ToDictionary(item => item.CharacterKey, StringComparer.OrdinalIgnoreCase);
var assignedItemIds = existingAssignments.Values
.Where(item => item.IllustrationLibraryItemID.HasValue)
.Select(item => item.IllustrationLibraryItemID!.Value)
.ToHashSet();
var duplicateExistingItemIds = existingAssignments.Values
.Where(item => item.IllustrationLibraryItemID.HasValue)
.GroupBy(item => item.IllustrationLibraryItemID!.Value)
.Where(group => group.Select(item => item.CharacterKey).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
.Select(group => group.Key)
.ToHashSet();
var retainedExistingItemIds = new HashSet<int>();
var observations = BuildCharacterObservations(prototype);
foreach (var observation in observations.Values.OrderByDescending(item => item.Significance).ThenBy(item => item.Name))
{
var existing = existingAssignments.GetValueOrDefault(observation.CharacterKey);
var ownExistingItemId = existing?.IllustrationLibraryItemID;
if (ownExistingItemId.HasValue && duplicateExistingItemIds.Contains(ownExistingItemId.Value) && !retainedExistingItemIds.Add(ownExistingItemId.Value))
{
ownExistingItemId = null;
existing = null;
}
var allScores = catalogue
.Select(candidate => Score(
candidate,
observation,
assignedItemIds.Contains(candidate.Item.IllustrationLibraryItemID)
&& candidate.Item.IllustrationLibraryItemID != ownExistingItemId))
.OrderByDescending(score => score.Score)
.ThenBy(score => score.IsAlreadyAssignedToAnotherSignificantCharacter)
.ThenByDescending(score => score.Candidate.Item.Status == IllustrationLibraryStatuses.Approved)
.ThenByDescending(score => score.Candidate.Item.UpdatedUtc)
.ToList();
var existingScore = existing?.IllustrationLibraryItemID is int existingItemId
? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId)
: null;
var best = allScores
.Where(score => !score.IsAlreadyAssignedToAnotherSignificantCharacter)
.FirstOrDefault(score => score.Score >= SuitableScore);
var chosen = ChooseAssignment(observation, existing, existingScore, best);
if (chosen is null)
{
var demandStatus = await RecordDemandAsync(projectId, observation, allScores);
ApplyDemandDiagnostics(prototype, observation, allScores, demandStatus);
continue;
}
var reasonReassigned = existingScore is not null && chosen.Candidate.Item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID
? ReassignmentReason(observation, existingScore, chosen)
: null;
var reason = reasonReassigned ?? (existing is null
? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence."
: "Preserved existing Story Intelligence character illustration assignment.");
var candidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
var evidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions);
var saved = await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave(
projectId,
observation.CharacterKey,
observation.Name,
chosen.Candidate.Item.IllustrationLibraryItemID,
chosen.Candidate.Item.StableCode,
Confidence(chosen.Score),
chosen.Score,
evidenceJson,
candidateDiagnostics,
reason,
reasonReassigned));
assignedItemIds.Add(chosen.Candidate.Item.IllustrationLibraryItemID);
ApplyAssignment(prototype, observation, chosen, saved, reason, reasonReassigned, candidateDiagnostics);
}
}
private MatchScore? ChooseAssignment(
CharacterObservation observation,
StoryIntelligenceCharacterIllustrationAssignment? existing,
MatchScore? existingScore,
MatchScore? best)
{
if (existingScore is not null && existingScore.Candidate.PublicUrl is not null)
{
if (best is null)
{
return existingScore;
}
var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence);
if (materiallyWrong && observation.Evidence.HasStrongPresentationEvidence)
{
return best;
}
if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin)
{
return existingScore;
}
}
if (best is not null)
{
return best;
}
return existing is null ? null : existingScore;
}
private static bool MateriallyWrong(CharacterIllustrationMetadata candidate, CharacterEvidence evidence)
=> KnownMismatch(evidence.AgeBand, candidate.AgeBand)
|| KnownMismatch(evidence.Presentation, candidate.Presentation)
|| KnownMismatch(evidence.HairColour, candidate.HairColour)
|| KnownMismatch(evidence.SkinTone, candidate.SkinTone);
private static bool KnownMismatch(string value, string candidate)
=> !IsUnknown(value) && !IsUnknown(candidate) && !string.Equals(value, candidate, StringComparison.OrdinalIgnoreCase);
private async Task<string> RecordDemandAsync(int projectId, CharacterObservation observation, IReadOnlyList<MatchScore> allScores)
{
var evidence = observation.Evidence;
var request = new StoryIntelligenceIllustrationDemandRequest(
projectId,
DemandKey(evidence),
evidence.AgeBand,
evidence.Presentation,
evidence.HairColour,
evidence.SkinTone,
evidence.HairLength,
evidence.Glasses,
evidence.FacialHair);
var demand = await assignments.UpsertDemandAsync(request);
var status = $"Demand recorded ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})";
if (demand.RequestCount >= DemandGenerationThreshold)
{
var specification = DemandSpecification(demand);
var prompt = promptBuilder.Build(specification);
var item = await library.UpsertPlannedAsync(specification, prompt);
var queued = item.Status == IllustrationLibraryStatuses.Planned
? await library.QueueAsync(item.IllustrationLibraryItemID, null)
: 0;
status = queued > 0
? $"Demand threshold reached; queued {item.StableCode}"
: $"Demand threshold reached; {item.StableCode} already active";
}
logger.LogInformation(
"Recorded Story Intelligence illustration demand {DemandKey} for project {ProjectId}. Request count {RequestCount}. Best score {BestScore}. Status {DemandStatus}.",
demand.DemandKey,
projectId,
demand.RequestCount,
allScores.FirstOrDefault()?.Score ?? 0,
status);
return status;
}
private static string DemandKey(CharacterEvidence evidence)
=> string.Join('|', evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone, evidence.HairLength, evidence.Glasses, evidence.FacialHair).ToLowerInvariant();
private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand)
{
var age = NormaliseUnknown(demand.AgeBand, "YoungAdult");
var presentation = NormaliseUnknown(demand.Presentation, "Androgynous");
var hairColour = NormaliseUnknown(demand.HairColour, "Brown");
var hairLength = NormaliseUnknown(demand.HairLength, "Medium");
var skinTone = NormaliseUnknown(demand.SkinTone, "Medium");
var features = new List<string> { "DemandGenerated" };
if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase))
{
features.Add("Glasses");
}
if (!IsUnknown(demand.FacialHair))
{
features.Add(demand.FacialHair!);
}
return new IllustrationGenerationSpecification
{
Category = IllustrationLibraryCategories.Character,
Code = $"char-demand-{StableHash(demand.DemandKey)}",
Title = $"{age} {presentation.ToLowerInvariant()} character",
Description = "Demand-created reusable character illustration for Story Intelligence matching.",
VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair and {skinTone.ToLowerInvariant()} skin tone range.",
Composition = "clear head-and-shoulders portrait with bright readable face, clean background separation, suitable for circular cropping",
Mood = "neutral, story-ready, emotionally readable",
ApparentAgeBand = age,
Presentation = presentation,
HairColour = hairColour,
HairLength = hairLength,
SkinTone = skinTone,
ClothingEra = "Contemporary",
ClothingStyle = "Plain reusable story wardrobe",
Features = features,
Palette = ["clear blue", "warm skin tones", "fresh accent colour"],
MetadataTags = ["character", "demand-generated", "story-intelligence"],
Metadata = new(StringComparer.OrdinalIgnoreCase)
{
["libraryPurpose"] = "Story Intelligence demand-generated identifier",
["privacy"] = "generic-no-manuscript-data",
["style"] = "editorial-stylised-realism",
["demandKey"] = demand.DemandKey,
["requestCount"] = demand.RequestCount.ToString("N0")
}
};
}
private static Dictionary<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype)
{
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);
foreach (var scene in prototype.Scenes)
{
foreach (var character in scene.Characters)
{
if (string.IsNullOrWhiteSpace(character.Id) || string.IsNullOrWhiteSpace(character.Name))
{
continue;
}
if (!observations.TryGetValue(character.Id, out var observation))
{
observation = new CharacterObservation(character.Id, character.Name);
observations[character.Id] = observation;
}
observation.Significance = Math.Max(observation.Significance, character.Weight);
observation.TextEvidence.Add(character.Name);
observation.TextEvidence.Add(character.Role);
observation.TextEvidence.Add(character.Relevance);
observation.IdentityEvidence.Add(character.Name);
observation.IdentityEvidence.Add(character.Role);
observation.IdentityEvidence.Add(character.Relevance);
foreach (var relationship in scene.Relationships.Where(item => string.Equals(item.SourceId, character.Id, StringComparison.OrdinalIgnoreCase)
|| string.Equals(item.TargetId, character.Id, StringComparison.OrdinalIgnoreCase)))
{
observation.TextEvidence.Add(relationship.Label);
observation.TextEvidence.Add(relationship.State);
observation.RelationshipEvidence.Add(relationship.Label);
observation.RelationshipEvidence.Add(relationship.State);
}
}
}
foreach (var observation in observations.Values)
{
observation.Evidence = InferEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence);
}
return observations;
}
private static CharacterEvidence InferEvidence(string characterName, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues)
{
var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var relationshipText = $" {string.Join(' ', relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var text = $"{identityText} {relationshipText}";
var relationshipPresentation = RelationshipPresentation(text);
var pronounPresentation = PronounPresentation(text);
var namePresentation = NamePresentation(identityText);
var presentation = FirstKnown(relationshipPresentation.Value, pronounPresentation.Value, namePresentation.Value, "Androgynous");
var cleanName = characterName.Trim().ToLowerInvariant();
var age = cleanName is "beth" or "grace" or "rosie" ? "Older Teen"
: ContainsAny(identityText, "grandfather", "grandmother", "elderly", "old man", "old woman", "older woman", "older man") ? "Elderly"
: ContainsAny(identityText, "middle aged", "middle-aged", "mother", " mum ", "father", " dad ", "parent", "aunt", "uncle") ? "Middle Aged"
: ContainsAny(identityText, "young teen", "child", "boy", "girl") ? "Child"
: ContainsAny(identityText, "older teen", "teenage", "teenager", "fifteen", "sixteen", "seventeen", " beth ", " grace ", " rosie ") ? "Older Teen"
: ContainsAny(identityText, "young adult", "student", "young woman", "young man") ? "Young Adult"
: "Unknown";
var hairColour = ContainsAny(text, "black hair", "dark hair") ? "Black"
: ContainsAny(text, "brown hair", "brunette") ? "Brown"
: ContainsAny(text, "blonde", "fair hair") ? "Blonde"
: ContainsAny(text, "red hair", "red-haired", "auburn", "ginger") ? "Red"
: ContainsAny(text, "grey hair", "gray hair") ? "Grey"
: ContainsAny(text, "white hair") ? "White"
: "Unknown";
var skinTone = ContainsAny(text, "light skin", "pale skin", "fair skin") ? "Light"
: ContainsAny(text, "medium skin", "olive skin", "brown skin") ? "Medium"
: ContainsAny(text, "dark skin", "deep skin", "black skin") ? "Dark"
: "Unknown";
var hairLength = ContainsAny(text, "bald", "shaved head") ? "Bald"
: ContainsAny(text, "short hair") ? "Short"
: ContainsAny(text, "long hair") ? "Long"
: ContainsAny(text, "medium hair", "shoulder length") ? "Medium"
: "Unknown";
var glasses = ContainsAny(text, "glasses", "spectacles") ? "Yes" : "Unknown";
var facialHair = ContainsAny(text, "moustache", "mustache") ? "Moustache"
: ContainsAny(text, "beard", "bearded") ? "Beard"
: "Unknown";
return new(
age,
presentation,
hairColour,
skinTone,
hairLength,
glasses,
facialHair,
relationshipPresentation.Confidence,
namePresentation.Confidence,
pronounPresentation.Confidence);
}
private static bool ContainsAny(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
private static EvidenceSignal RelationshipPresentation(string text)
{
if (ContainsAny(text, "mother", " mum", "aunt", "sister", "daughter", "wife", "grandmother", " niece"))
{
return new("Feminine", 0.92m);
}
if (ContainsAny(text, "father", " dad", "uncle", "brother", "son", "husband", "grandfather", " nephew"))
{
return new("Masculine", 0.92m);
}
return EvidenceSignal.Unknown;
}
private static EvidenceSignal PronounPresentation(string text)
{
if (ContainsAny(text, " she ", " her ", " hers ", " herself "))
{
return new("Feminine", 0.86m);
}
if (ContainsAny(text, " he ", " him ", " his ", " himself "))
{
return new("Masculine", 0.86m);
}
return EvidenceSignal.Unknown;
}
private static EvidenceSignal NamePresentation(string text)
{
if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " annie ", " helen ", " elen "))
{
return new("Feminine", 0.82m);
}
if (ContainsAny(text, " graham ", " george ", " john ", " david ", " simon ", " kevin ", " colin "))
{
return new("Masculine", 0.82m);
}
return EvidenceSignal.Unknown;
}
private static string FirstKnown(params string[] values)
=> values.FirstOrDefault(value => !IsUnknown(value) && !string.Equals(value, "Androgynous", StringComparison.OrdinalIgnoreCase)) ?? values.LastOrDefault() ?? "Unknown";
private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned)
{
var score = 0;
var rejected = new List<string>();
AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band");
AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, observation.Evidence.HasStrongPresentationEvidence ? 42 : 25, "presentation");
AddScore(observation.Evidence.HairColour, candidate.Metadata.HairColour, 20, "hair colour");
AddScore(observation.Evidence.SkinTone, candidate.Metadata.SkinTone, 20, "skin tone");
AddScore(observation.Evidence.HairLength, candidate.Metadata.HairLength, 8, "hair length");
AddScore(observation.Evidence.Glasses, candidate.Metadata.Glasses, 7, "glasses");
AddScore(observation.Evidence.FacialHair, candidate.Metadata.FacialHair, 7, "facial hair");
if (candidate.Item.Status == IllustrationLibraryStatuses.Approved)
{
score += 3;
}
if (observation.Evidence.HasStrongPresentationEvidence && KnownMismatch(observation.Evidence.Presentation, candidate.Metadata.Presentation))
{
score -= 60;
rejected.Add($"strong presentation evidence rejected {candidate.Metadata.Presentation}");
}
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)
{
if (IsUnknown(evidence))
{
score += weight / 2;
return;
}
if (string.Equals(evidence, value, StringComparison.OrdinalIgnoreCase))
{
score += weight;
return;
}
if (IsUnknown(value))
{
score += weight / 3;
rejected.Add($"{label} unknown in illustration metadata");
return;
}
rejected.Add($"{label} mismatch: wanted {evidence}, candidate {value}");
}
}
private static decimal Confidence(int score)
=> Math.Round(Math.Clamp(score, 0, 100) / 100m, 2);
private static string ReassignmentReason(CharacterObservation observation, MatchScore existing, MatchScore chosen)
=> $"Reassigned {observation.Name} because new structured evidence made {existing.Candidate.Item.StableCode} materially weaker than {chosen.Candidate.Item.StableCode}.";
private void ApplyAssignment(
StoryIntelligenceExperiencePrototypeViewModel prototype,
CharacterObservation observation,
MatchScore chosen,
StoryIntelligenceCharacterIllustrationAssignment saved,
string reason,
string? reasonReassigned,
string candidateDiagnostics)
{
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 = chosen.Candidate.Item.StableCode;
character.ImagePath = chosen.Candidate.PublicUrl!;
character.ImageResolution = new StoryIntelligenceExperienceImageResolution
{
EntityId = character.Id,
StableCode = chosen.Candidate.Item.StableCode,
IllustrationLibraryItemId = chosen.Candidate.Item.IllustrationLibraryItemID,
Status = chosen.Candidate.Item.Status,
FinalImageUrl = chosen.Candidate.PublicUrl,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = false,
AssignmentConfidence = saved.MatchConfidence,
MatchingScore = saved.MatchingScore,
RejectedCandidates = candidateDiagnostics,
ReasonChosen = reason,
ReasonReassigned = reasonReassigned,
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, string demandStatus)
{
var diagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase)))
{
var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl)
? character.ImagePath
: character.ImageResolution.FallbackImageUrl;
character.LibraryCode = string.Empty;
if (!string.IsNullOrWhiteSpace(fallbackImageUrl))
{
character.ImagePath = fallbackImageUrl;
}
character.ImageResolution = new StoryIntelligenceExperienceImageResolution
{
EntityId = character.Id,
StableCode = string.Empty,
IllustrationLibraryItemId = null,
Status = "DemandRecorded",
FinalImageUrl = null,
FallbackImageUrl = fallbackImageUrl,
FallbackUsed = true,
MatchingScore = allScores.FirstOrDefault()?.Score,
RejectedCandidates = diagnostics,
ReasonChosen = "No suitable unused illustration matched structured character evidence.",
DemandStatus = 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"
};
}
}
private string? ResolveExistingPublicUploadUrl(string? storedPath)
{
var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library");
if (physicalPath is null || !File.Exists(physicalPath))
{
return null;
}
var normalised = storedPath!.Replace('\\', '/').Trim();
if (!normalised.StartsWith('/'))
{
normalised = "/" + normalised.TrimStart('/');
}
return EncodeUrlPath(normalised);
}
private static string EncodeUrlPath(string path)
=> string.Join(
'/',
path.Split('/', StringSplitOptions.None)
.Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment)));
private static bool IsUnknown(string? value)
=> string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase);
private static string NormaliseUnknown(string? value, string fallback)
=> IsUnknown(value) ? fallback : value!;
private static string StableHash(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12].ToLowerInvariant();
private sealed class CharacterObservation(string characterKey, string name)
{
public string CharacterKey { get; } = characterKey;
public string Name { get; } = name;
public int Significance { get; set; }
public bool IsSignificant => Significance >= 50;
public List<string> TextEvidence { get; } = [];
public List<string> IdentityEvidence { get; } = [];
public List<string> RelationshipEvidence { get; } = [];
public CharacterEvidence Evidence { get; set; } = CharacterEvidence.Unknown;
}
private sealed record EvidenceSignal(string Value, decimal Confidence)
{
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)
{
public static CharacterCandidate? From(IllustrationLibraryItem item)
{
var metadata = CharacterIllustrationMetadata.From(item);
return metadata is null ? null : new(item, metadata, null);
}
}
private sealed record CharacterIllustrationMetadata(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair)
{
public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item)
{
IllustrationGenerationSpecification? specification = null;
Dictionary<string, string>? metadata = null;
if (!string.IsNullOrWhiteSpace(item.MetadataJson))
{
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(item.MetadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
if (!string.IsNullOrWhiteSpace(item.SpecificationJson))
{
try
{
specification = JsonSerializer.Deserialize<IllustrationGenerationSpecification>(item.SpecificationJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
return new(
NormaliseAge(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand, item.StableCode),
NormalisePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation, item.StableCode),
NormaliseHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour, item.StableCode),
NormaliseSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone, item.StableCode),
NormaliseHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength, item.StableCode),
FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"),
FirstMetadata(metadata, "facialHair") ?? (FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown"));
}
private static string? FirstMetadata(Dictionary<string, string>? metadata, params string[] keys)
=> keys.Select(key => metadata?.GetValueOrDefault(key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static bool FeatureValue(IReadOnlyList<string>? features, string value)
=> features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true;
private static string NormaliseAge(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("child")) return "Child";
if (text.Contains("youngteen")) return "Young Teen";
if (text.Contains("teen")) return "Older Teen";
if (text.Contains("youngadult") || text.Contains("young-adult")) return "Young Adult";
if (text.Contains("middleaged") || text.Contains("middle-aged")) return "Middle Aged";
if (text.Contains("elderly") || text.Contains("older")) return "Elderly";
return "Unknown";
}
private static string NormalisePresentation(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("feminine") || text.Contains("woman") || text.Contains("female")) return "Feminine";
if (text.Contains("masculine") || text.Contains("man") || text.Contains("male")) return "Masculine";
if (text.Contains("androgynous")) return "Androgynous";
return "Androgynous";
}
private static string NormaliseHairColour(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("black")) return "Black";
if (text.Contains("brown") || text.Contains("brunette") || text.Contains("darkbrown")) return "Brown";
if (text.Contains("blonde")) return "Blonde";
if (text.Contains("red") || text.Contains("auburn")) return "Red";
if (text.Contains("grey") || text.Contains("gray")) return "Grey";
if (text.Contains("white")) return "White";
return "Unknown";
}
private static string NormaliseSkinTone(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("deep") || text.Contains("dark")) return "Dark";
if (text.Contains("medium") || text.Contains("lightmedium")) return "Medium";
if (text.Contains("light")) return "Light";
return "Unknown";
}
private static string NormaliseHairLength(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("bald")) return "Bald";
if (text.Contains("short")) return "Short";
if (text.Contains("long")) return "Long";
if (text.Contains("medium")) return "Medium";
return "Unknown";
}
}
private sealed record MatchScore(CharacterCandidate Candidate, int Score, bool IsAlreadyAssignedToAnotherSignificantCharacter, IReadOnlyList<string> Rejections);
private sealed record MatchDiagnostics(int IllustrationLibraryItemID, string StableCode, int Score, IReadOnlyList<string> Rejections)
{
public static MatchDiagnostics From(MatchScore score)
=> new(score.Candidate.Item.IllustrationLibraryItemID, score.Candidate.Item.StableCode, score.Score, score.Rejections);
}
}