Phase 21K - Forensic Illustration Matching Repair

This commit is contained in:
Nick Beckley 2026-07-13 20:05:37 +00:00
parent ca73c9e6f3
commit 2d2055f14a
9 changed files with 852 additions and 174 deletions

View File

@ -40,7 +40,13 @@ var tests = new (string Name, Action Test)[]
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel),
("Illustration provider omits GPT image response format", IllustrationProviderOmitsGptImageResponseFormat)
("Illustration provider omits GPT image response format", IllustrationProviderOmitsGptImageResponseFormat),
("Illustration matching rejects adult masculine unknown art for Beth", IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth),
("Illustration matching treats Mrs title as hard feminine evidence", IllustrationMatchingTreatsMrsTitleAsHardFeminineEvidence),
("Illustration matching infers mother and aunt as mature feminine", IllustrationMatchingInfersMotherAndAuntAsMatureFeminine),
("Illustration matching rejects duplicate named portrait allocation", IllustrationMatchingRejectsDuplicateNamedPortraitAllocation),
("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches),
("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate)
};
foreach (var test in tests)
@ -453,6 +459,97 @@ static void StoryIntelligencePrototypeUsesStarterIllustrationCodes()
Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution.");
}
static void IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth()
{
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth", "age 15", "fifteen-year-old girl"], []);
var middleAged = Metadata("char-family-relative", "MatureAdult", "Feminine");
var masculine = Metadata("char-middle-aged-weathered-man", "MatureAdult", "Masculine");
var unknown = Metadata("char-unknown-figure", "YoungAdult", "Androgynous", isUnknownFigure: true);
Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen, $"Beth age should be YoungTeen, got {beth.AgeBand}.");
Assert(Rejected(beth, middleAged).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Beth should reject mature adult art.");
Assert(Rejected(beth, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Beth should reject masculine art.");
Assert(Rejected(beth, unknown).Any(reason => reason.Contains("Unknown Figure", StringComparison.OrdinalIgnoreCase)), "Beth should reject Unknown Figure art.");
}
static void IllustrationMatchingTreatsMrsTitleAsHardFeminineEvidence()
{
var mrsPatterson = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson", "neighbour"], []);
var masculine = Metadata("char-archival-contact", "Senior", "Masculine");
var androgynous = Metadata("char-neighbourhood-friend", "YoungAdult", "Androgynous");
var feminine = Metadata("char-family-relative", "MatureAdult", "Feminine");
Assert(mrsPatterson.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Mrs title should infer Feminine, got {mrsPatterson.Presentation}.");
Assert(Rejected(mrsPatterson, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Mrs Patterson should reject masculine art.");
Assert(Rejected(mrsPatterson, androgynous).Count == 0, "Androgynous should not be hard-rejected solely by title.");
Assert(Rejected(mrsPatterson, feminine).Count == 0, "Suitable feminine candidate should remain valid.");
}
static void IllustrationMatchingInfersMotherAndAuntAsMatureFeminine()
{
var mother = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth's mother", ["her mother", "mother of Beth", "Beth is 15"], []);
var aunt = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Aunt Elen", ["Beth's aunt", "Aunt Elen"], []);
var teen = Metadata("char-young-helper", "YoungTeen", "Androgynous");
var masculine = Metadata("char-middle-aged-weathered-man", "MatureAdult", "Masculine");
Assert(mother.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen || mother.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Unexpected mother age inference {mother.AgeBand}.");
Assert(mother.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Mother should infer Feminine.");
Assert(aunt.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Aunt should infer MatureAdult, got {aunt.AgeBand}.");
Assert(aunt.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Aunt should infer Feminine.");
Assert(Rejected(mother, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Mother should reject masculine art.");
Assert(Rejected(aunt, teen).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Aunt should reject teen art.");
}
static void IllustrationMatchingRejectsDuplicateNamedPortraitAllocation()
{
var rosie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Rosie", ["Rosie", "young woman"], []);
var candidate = Metadata("char-young-adult-red-haired-witness", "YoungAdult", "Feminine");
var rejected = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(rosie, candidate, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: true);
Assert(rejected.Any(reason => reason.Contains("already assigned", StringComparison.OrdinalIgnoreCase)), "Distinct significant named characters should not silently share portraits.");
}
static void IllustrationSemanticTypesPreventObviousMismatches()
{
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom at Beth's house") == "Bathroom", "Bathroom should classify as Bathroom.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "Office"), "Bathroom must reject office imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "HouseExterior"), "Kitchen must reject house exterior imagery.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "FlatInterior"), "Kitchen may use generic interior fallback.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ambulance") == "Ambulance", "Ambulance should classify as Ambulance.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Ambulance", "Car"), "Ambulance must reject sports car imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("BodyBag", "Suitcase"), "Body bag must reject suitcase/box imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Note", "Suitcase"), "Note must reject suitcase imagery.");
}
static void IllustrationDemandArchetypesStayBroadAndUseActiveTemplate()
{
var evidence = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth", "age 15", "red hair", "light skin"], []);
var key = string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant();
Assert(key == "character|youngteen|feminine|red|light", $"Demand key was too broad or too specific: {key}");
Assert(!key.Contains("beth", StringComparison.OrdinalIgnoreCase), "Demand key must not include character name.");
Assert(!key.Contains("eye", StringComparison.OrdinalIgnoreCase), "Demand key must not include eye colour.");
Assert(IllustrationPromptBuilder.CurrentTemplateVersion == "21J.3", $"Demand generation should use the active shared template, got {IllustrationPromptBuilder.CurrentTemplateVersion}.");
}
static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false)
=> new(
ageBand,
presentation,
"Unknown",
"Unknown",
"Unknown",
"Unknown",
"Unknown",
isUnknownFigure,
false,
!isUnknownFigure,
IllustrationPromptBuilder.CurrentTemplateVersion,
"Test");
static IReadOnlyList<string> Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata)
=> StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false);
static void StoryIntelligenceSimulationModeRemainsAvailable()
{
var prototype = StoryIntelligenceExperiencePrototypeData.Build();

View File

@ -83,6 +83,28 @@ public sealed class DevelopmentController(
return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in."));
}
[HttpGet("StoryIntelligenceIllustrationDiagnostics")]
public async Task<IActionResult> StoryIntelligenceIllustrationDiagnostics([FromQuery] int importSessionId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0 || currentUser.UserId is not int userId)
{
return BadRequest("A valid importSessionId is required.");
}
var model = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId);
if (model is null)
{
return NotFound($"Import Session {importSessionId:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet.");
}
return View(model);
}
private static StoryIntelligenceExperiencePrototypeViewModel ErrorModel(string requestedMode, int? importSessionId, string message)
=> new()
{

View File

@ -49,10 +49,14 @@ public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnecti
CandidateDiagnosticsJson = @CandidateDiagnosticsJson,
ReasonChosen = @ReasonChosen,
ReasonReassigned = @ReasonReassigned,
AssignmentStatus = CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END,
LastValidatedUtc = SYSUTCDATETIME(),
EvidenceVersion = N'21K',
IsFallback = CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned)
VALUES (@ProjectID, @CharacterKey, @CharacterName, @IllustrationLibraryItemID, @StableCode, @MatchConfidence, @MatchingScore, @EvidenceJson, @CandidateDiagnosticsJson, @ReasonChosen, @ReasonReassigned);
INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned, AssignmentStatus, AssignedUtc, LastValidatedUtc, EvidenceVersion, IsFallback)
VALUES (@ProjectID, @CharacterKey, @CharacterName, @IllustrationLibraryItemID, @StableCode, @MatchConfidence, @MatchingScore, @EvidenceJson, @CandidateDiagnosticsJson, @ReasonChosen, @ReasonReassigned, CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END, SYSUTCDATETIME(), SYSUTCDATETIME(), N'21K', CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END);
SELECT *
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments

View File

@ -14,6 +14,14 @@ public sealed class StoryIntelligenceCharacterIllustrationAssignment
public string? CandidateDiagnosticsJson { get; init; }
public string? ReasonChosen { get; init; }
public string? ReasonReassigned { get; init; }
public int? ImportSessionID { get; init; }
public string AssignmentStatus { get; init; } = "Assigned";
public DateTime? AssignedUtc { get; init; }
public DateTime? LastValidatedUtc { get; init; }
public DateTime? InvalidatedUtc { get; init; }
public string? InvalidationReason { get; init; }
public string EvidenceVersion { get; init; } = "21K";
public bool IsFallback { get; init; }
public bool IsActive { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
@ -46,6 +54,8 @@ public sealed class StoryIntelligenceIllustrationDemand
public string? FacialHair { get; init; }
public int RequestCount { get; init; }
public DateTime LastRequestedUtc { get; init; }
public DateTime? QueuedUtc { get; init; }
public string GenerationSource { get; init; } = "Demand generation";
}
public sealed record StoryIntelligenceIllustrationDemandRequest(

View File

@ -0,0 +1,405 @@
using System.Text.Json;
using PlotLine.Models;
namespace PlotLine.Services;
public static class StoryIntelligenceIllustrationCompatibility
{
public static class AgeBands
{
public const string Child = "Child";
public const string YoungTeen = "YoungTeen";
public const string OlderTeen = "OlderTeen";
public const string YoungAdult = "YoungAdult";
public const string Adult = "Adult";
public const string MatureAdult = "MatureAdult";
public const string Senior = "Senior";
public const string Unknown = "Unknown";
}
public static class Presentations
{
public const string Feminine = "Feminine";
public const string Masculine = "Masculine";
public const string Androgynous = "Androgynous";
public const string Unknown = "Unknown";
}
public static class HairColours
{
public const string Black = "Black";
public const string Brown = "Brown";
public const string Blonde = "Blonde";
public const string Red = "Red";
public const string Grey = "Grey";
public const string White = "White";
public const string Bald = "Bald";
public const string Unknown = "Unknown";
}
public static class SkinTones
{
public const string Light = "Light";
public const string Medium = "Medium";
public const string Dark = "Dark";
public const string Unknown = "Unknown";
}
public static CharacterEvidenceProfile InferCharacterEvidence(string name, 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 allText = $"{identityText} {relationshipText}";
var titlePresentation = TitlePresentation(identityText);
var relationshipPresentation = RelationshipPresentation(allText);
var pronounPresentation = PronounPresentation(allText);
var namePresentation = NamePresentation($" {name.ToLowerInvariant()} {identityText} ");
var presentation = FirstKnown(
titlePresentation.Value,
relationshipPresentation.Value,
pronounPresentation.Value,
namePresentation.Value,
Presentations.Unknown);
var presentationConfidence = new[] { titlePresentation.Confidence, relationshipPresentation.Confidence, pronounPresentation.Confidence, namePresentation.Confidence }.Max();
var explicitAge = ExplicitAgeBand(allText);
var relationshipAge = RelationshipAgeBand(allText);
var namedAge = NameAgeBand(name);
var age = FirstKnown(relationshipAge.Value, explicitAge.Value, namedAge.Value, AgeBands.Unknown);
var ageConfidence = new[] { explicitAge.Confidence, relationshipAge.Confidence, namedAge.Confidence }.Max();
return new(
age,
presentation,
HairColour(allText),
SkinTone(allText),
HairLength(allText),
ContainsAny(allText, "glasses", "spectacles") ? "Yes" : "Unknown",
ContainsAny(allText, "moustache", "mustache") ? "Moustache" : ContainsAny(allText, "beard", "bearded") ? "Beard" : "Unknown",
ageConfidence,
presentationConfidence,
relationshipPresentation.Confidence,
namePresentation.Confidence,
pronounPresentation.Confidence);
}
public static IllustrationCharacterMetadata CharacterMetadataFrom(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)
{
}
}
var stableCode = item.StableCode ?? string.Empty;
var isUnknownFigure = BoolMetadata(metadata, "isUnknownFigure") || stableCode.Contains("unknown-figure", StringComparison.OrdinalIgnoreCase);
var isGenericFallback = BoolMetadata(metadata, "isGenericFallback") || stableCode.Contains("fallback", StringComparison.OrdinalIgnoreCase);
var suitableForNamed = !isUnknownFigure && !isGenericFallback && !BoolMetadata(metadata, "notSuitableForNamedCharacter");
return new(
NormalizeAgeBand(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand ?? stableCode),
NormalizePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation ?? stableCode),
NormalizeHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour ?? stableCode),
NormalizeSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone ?? stableCode),
NormalizeHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength ?? stableCode),
FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"),
FirstMetadata(metadata, "facialHair") ?? (FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown"),
isUnknownFigure,
isGenericFallback,
suitableForNamed,
item.PromptTemplateVersion,
FirstMetadata(metadata, "generationSource", "source") ?? string.Empty);
}
public static IReadOnlyList<string> CharacterHardRejections(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata candidate, bool isNamedCharacter, bool alreadyAssignedToAnotherSignificantCharacter)
{
var rejected = new List<string>();
if (isNamedCharacter && candidate.IsUnknownFigure)
{
rejected.Add("named character cannot use Unknown Figure artwork");
}
if (isNamedCharacter && !candidate.IsSuitableForNamedCharacter)
{
rejected.Add("candidate is not suitable for strict named-character matching");
}
if (AgeIncompatible(evidence.AgeBand, candidate.AgeBand))
{
rejected.Add($"age hard mismatch: wanted {evidence.AgeBand}, candidate {candidate.AgeBand}");
}
if (evidence.HasStrongPresentationEvidence
&& PresentationIncompatible(evidence.Presentation, candidate.Presentation))
{
rejected.Add($"presentation hard mismatch: wanted {evidence.Presentation}, candidate {candidate.Presentation}");
}
if (ExplicitKnown(evidence.HairColour, evidence.AgeConfidence) && KnownMismatch(evidence.HairColour, candidate.HairColour))
{
rejected.Add($"hair colour hard mismatch: wanted {evidence.HairColour}, candidate {candidate.HairColour}");
}
if (alreadyAssignedToAnotherSignificantCharacter && isNamedCharacter)
{
rejected.Add("already assigned to another significant named character");
}
return rejected;
}
public static string NormalizeAgeBand(string? value)
{
var text = (value ?? string.Empty).Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase).Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
if (text.Contains("youngteen") || text.Contains("13") || text.Contains("14") || text.Contains("15") || text.Contains("fifteen")) return AgeBands.YoungTeen;
if (text.Contains("olderteen") || text.Contains("teen") || text.Contains("16") || text.Contains("17") || text.Contains("18") || text.Contains("19")) return AgeBands.OlderTeen;
if (text.Contains("child") || text.Contains("boy") || text.Contains("girl")) return AgeBands.Child;
if (text.Contains("youngadult")) return AgeBands.YoungAdult;
if (text.Contains("middleaged") || text.Contains("matureadult") || text.Contains("parent") || text.Contains("mother") || text.Contains("father") || text.Contains("aunt") || text.Contains("uncle")) return AgeBands.MatureAdult;
if (text.Contains("adult")) return AgeBands.Adult;
if (text.Contains("senior") || text.Contains("elderly") || text.Contains("older") || text.Contains("grandmother") || text.Contains("grandfather")) return AgeBands.Senior;
return AgeBands.Unknown;
}
public static string NormalizePresentation(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "feminine", "female", "woman", "girl", "mother", "mum", "mrs", "miss", "ms", "aunt", "sister", "daughter", "wife", "grandmother")) return Presentations.Feminine;
if (ContainsAny(text, "masculine", "male", "man", "boy", "father", "dad", "mr", "uncle", "brother", "son", "husband", "grandfather")) return Presentations.Masculine;
if (text.Contains("androgynous", StringComparison.OrdinalIgnoreCase)) return Presentations.Androgynous;
return Presentations.Unknown;
}
public static string NormalizeHairColour(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (text.Contains("bald", StringComparison.OrdinalIgnoreCase)) return HairColours.Bald;
if (text.Contains("black", StringComparison.OrdinalIgnoreCase)) return HairColours.Black;
if (ContainsAny(text, "brown", "brunette", "darkbrown", "dark hair")) return HairColours.Brown;
if (ContainsAny(text, "blonde", "fair hair")) return HairColours.Blonde;
if (ContainsAny(text, "red", "auburn", "ginger")) return HairColours.Red;
if (ContainsAny(text, "grey", "gray")) return HairColours.Grey;
if (text.Contains("white", StringComparison.OrdinalIgnoreCase)) return HairColours.White;
return HairColours.Unknown;
}
public static string NormalizeSkinTone(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "deep", "dark", "black skin")) return SkinTones.Dark;
if (ContainsAny(text, "medium", "lightmedium", "olive", "brown skin")) return SkinTones.Medium;
if (ContainsAny(text, "light", "pale", "fair skin")) return SkinTones.Light;
return SkinTones.Unknown;
}
public static string NormalizeHairLength(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (text.Contains("bald", StringComparison.OrdinalIgnoreCase)) return "Bald";
if (text.Contains("short", StringComparison.OrdinalIgnoreCase)) return "Short";
if (text.Contains("long", StringComparison.OrdinalIgnoreCase)) return "Long";
if (text.Contains("medium", StringComparison.OrdinalIgnoreCase) || text.Contains("shoulder", StringComparison.OrdinalIgnoreCase)) return "Medium";
return "Unknown";
}
public static string LocationType(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom";
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway";
if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell";
if (ContainsAny(text, "front door", "porch", "entrance", "house", "exterior")) return "HouseExterior";
if (ContainsAny(text, "office", "desk", "study", "library")) return "Office";
if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School";
if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom";
if (text.Contains("street", StringComparison.OrdinalIgnoreCase)) return "Street";
if (ContainsAny(text, "car park", "parking")) return "CarPark";
if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop";
if (text.Contains("hospital", StringComparison.OrdinalIgnoreCase)) return "Hospital";
if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior";
return "UnknownInterior";
}
public static string AssetType(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (text.Contains("ambulance", StringComparison.OrdinalIgnoreCase)) return "Ambulance";
if (text.Contains("stretcher", StringComparison.OrdinalIgnoreCase)) return "Stretcher";
if (text.Contains("body bag", StringComparison.OrdinalIgnoreCase)) return "BodyBag";
if (text.Contains("bottle", StringComparison.OrdinalIgnoreCase)) return "Bottle";
if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack";
if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase";
if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note";
if (text.Contains("letter", StringComparison.OrdinalIgnoreCase)) return "Letter";
if (text.Contains("notebook", StringComparison.OrdinalIgnoreCase)) return "Notebook";
if (text.Contains("book", StringComparison.OrdinalIgnoreCase)) return "Book";
if (ContainsAny(text, "telephone", "phone", "recorder")) return "Telephone";
if (text.Contains("key", StringComparison.OrdinalIgnoreCase)) return "Keys";
if (ContainsAny(text, "car", "vehicle")) return "Car";
if (ContainsAny(text, "photo", "photograph")) return "Photograph";
if (ContainsAny(text, "document", "paper")) return "Document";
return "UnknownObject";
}
public static bool LocationCompatible(string requestedType, string candidateType)
=> requestedType == candidateType
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" or "WaitingRoom" && candidateType is "FlatInterior")
|| (requestedType == "Bathroom" && candidateType == "Bathroom");
public static bool AssetCompatible(string requestedType, string candidateType)
=> requestedType == candidateType
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
private static EvidenceSignal ExplicitAgeBand(string text)
{
if (ContainsAny(text, "15", "fifteen")) return new(AgeBands.YoungTeen, 0.98m);
if (ContainsAny(text, "13", "thirteen", "14", "fourteen")) return new(AgeBands.YoungTeen, 0.98m);
if (ContainsAny(text, "16", "sixteen", "17", "seventeen", "18", "eighteen", "19", "nineteen")) return new(AgeBands.OlderTeen, 0.98m);
if (ContainsAny(text, "child", "young girl", "young boy")) return new(AgeBands.Child, 0.86m);
if (ContainsAny(text, "young adult", "young woman", "young man")) return new(AgeBands.YoungAdult, 0.82m);
if (ContainsAny(text, "middle-aged", "middle aged", "mature adult")) return new(AgeBands.MatureAdult, 0.86m);
if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal RelationshipAgeBand(string text)
{
if (ContainsAny(text, "mother", " mum", "father", " dad", "parent", "aunt", "uncle", "teacher")) return new(AgeBands.MatureAdult, 0.74m);
if (ContainsAny(text, "grandmother", "grandfather")) return new(AgeBands.Senior, 0.82m);
if (ContainsAny(text, "daughter", "son", "sister", "brother")) return new(AgeBands.Unknown, 0.3m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal NameAgeBand(string name)
{
var clean = name.Trim().ToLowerInvariant();
return clean is "beth" ? new(AgeBands.YoungTeen, 0.86m) : EvidenceSignal.Unknown;
}
private static EvidenceSignal TitlePresentation(string text)
{
if (ContainsAny(text, " mrs ", " miss ", " ms ")) return new(Presentations.Feminine, 0.96m);
if (ContainsAny(text, " mr ")) return new(Presentations.Masculine, 0.96m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal RelationshipPresentation(string text)
{
if (ContainsAny(text, "mother", " mum", "mummy", "aunt", "sister", "daughter", "wife", "grandmother", " niece", "woman", "girl")) return new(Presentations.Feminine, 0.92m);
if (ContainsAny(text, "father", " dad", "daddy", "uncle", "brother", "son", "husband", "grandfather", " nephew", "man", "boy", "lad")) return new(Presentations.Masculine, 0.92m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal PronounPresentation(string text)
{
if (ContainsAny(text, " she ", " her ", " hers ", " herself ")) return new(Presentations.Feminine, 0.86m);
if (ContainsAny(text, " he ", " him ", " his ", " himself ")) return new(Presentations.Masculine, 0.86m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal NamePresentation(string text)
{
if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " annie ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m);
if (ContainsAny(text, " graham ", " george ", " john ", " david ", " simon ", " kevin ", " colin ")) return new(Presentations.Masculine, 0.82m);
return EvidenceSignal.Unknown;
}
private static string HairColour(string text) => NormalizeHairColour(text);
private static string SkinTone(string text) => NormalizeSkinTone(text);
private static string HairLength(string text) => NormalizeHairLength(text);
private static bool AgeIncompatible(string evidence, string candidate)
=> (evidence, candidate) switch
{
(AgeBands.Child, not AgeBands.Child) => true,
(AgeBands.YoungTeen, AgeBands.YoungAdult or AgeBands.Adult or AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.OlderTeen, AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.Adult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
(AgeBands.MatureAdult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
(AgeBands.Senior, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen or AgeBands.YoungAdult) => true,
_ => false
};
private static bool PresentationIncompatible(string evidence, string candidate)
=> (evidence, candidate) is (Presentations.Feminine, Presentations.Masculine) or (Presentations.Masculine, Presentations.Feminine);
private static bool ExplicitKnown(string value, decimal confidence)
=> !string.IsNullOrWhiteSpace(value) && value != "Unknown" && confidence >= 0.85m;
private static bool KnownMismatch(string evidence, string candidate)
=> !string.Equals(evidence, "Unknown", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(candidate, "Unknown", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(evidence, candidate, StringComparison.OrdinalIgnoreCase);
private static string FirstKnown(params string[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value) && !string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase)) ?? "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 BoolMetadata(Dictionary<string, string>? metadata, string key)
=> bool.TryParse(metadata?.GetValueOrDefault(key), out var value) && value;
private static bool FeatureValue(IReadOnlyList<string>? features, string value)
=> features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true;
private static bool ContainsAny(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
private sealed record EvidenceSignal(string Value, decimal Confidence)
{
public static EvidenceSignal Unknown { get; } = new("Unknown", 0);
}
}
public sealed record CharacterEvidenceProfile(
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string HairLength,
string Glasses,
string FacialHair,
decimal AgeConfidence,
decimal PresentationConfidence,
decimal RelationshipConfidence,
decimal NameConfidence,
decimal PronounConfidence)
{
public bool HasStrongPresentationEvidence => PresentationConfidence >= 0.8m;
}
public sealed record IllustrationCharacterMetadata(
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string HairLength,
string Glasses,
string FacialHair,
bool IsUnknownFigure,
bool IsGenericFallback,
bool IsSuitableForNamedCharacter,
string? PromptTemplateVersion,
string GenerationSource);

View File

@ -92,13 +92,27 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId)
: null;
var best = allScores
.Where(score => !score.IsAlreadyAssignedToAnotherSignificantCharacter)
.Where(score => score.Rejections.Count == 0)
.FirstOrDefault(score => score.Score >= SuitableScore);
var chosen = ChooseAssignment(observation, existing, existingScore, best);
if (chosen is null)
{
var demandStatus = await RecordDemandAsync(projectId, observation, allScores);
var fallbackCandidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
var fallbackEvidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions);
await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave(
projectId,
observation.CharacterKey,
observation.Name,
null,
null,
0,
0,
fallbackEvidenceJson,
fallbackCandidateDiagnostics,
"No hard-compatible unused illustration exists; persisted neutral fallback and recorded library demand.",
null));
ApplyDemandDiagnostics(prototype, observation, allScores, demandStatus);
continue;
}
@ -138,6 +152,11 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
{
if (existingScore is not null && existingScore.Candidate.PublicUrl is not null)
{
if (existingScore.Rejections.Count > 0)
{
return best;
}
if (best is null)
{
return existingScore;
@ -211,7 +230,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
}
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('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant();
private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand)
{
@ -301,7 +320,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
foreach (var observation in observations.Values)
{
observation.Evidence = InferEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence);
observation.Evidence = CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence));
}
return observations;
@ -309,53 +328,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
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);
return CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(characterName, identityValues, relationshipValues));
}
private static bool ContainsAny(string text, params string[] needles)
@ -412,7 +385,11 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned)
{
var score = 0;
var rejected = new List<string>();
var rejected = new List<string>(StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(
observation.Evidence.Profile,
candidate.Metadata.Profile,
isNamedCharacter: !IsUnresolvedName(observation.Name),
alreadyAssignedToAnotherSignificantCharacter: alreadyAssigned && observation.IsSignificant));
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");
@ -426,18 +403,6 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
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)
@ -469,7 +434,9 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
=> 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}.";
=> existing.Rejections.Count > 0
? $"Reassigned {observation.Name} because {existing.Candidate.Item.StableCode} violated hard constraints: {string.Join("; ", existing.Rejections)}."
: $"Reassigned {observation.Name} because new structured evidence made {existing.Candidate.Item.StableCode} materially weaker than {chosen.Candidate.Item.StableCode}.";
private void ApplyAssignment(
StoryIntelligenceExperiencePrototypeViewModel prototype,
@ -576,6 +543,15 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
private static bool IsUnknown(string? value)
=> string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase);
private static bool IsUnresolvedName(string? value)
{
var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
return string.IsNullOrWhiteSpace(clean)
|| clean is "unknown" or "unknown figure" or "unidentified person" or "unidentified woman" or "unidentified man" or "narrator"
|| clean.StartsWith("unknown ", StringComparison.Ordinal)
|| clean.StartsWith("unidentified ", StringComparison.Ordinal);
}
private static string NormaliseUnknown(string? value, string fallback)
=> IsUnknown(value) ? fallback : value!;
@ -609,10 +585,24 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
string FacialHair,
decimal RelationshipConfidence,
decimal NameConfidence,
decimal PronounConfidence)
decimal PronounConfidence,
CharacterEvidenceProfile Profile)
{
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);
public static CharacterEvidence Unknown { get; } = From(new CharacterEvidenceProfile("Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 0, 0, 0, 0, 0));
public static CharacterEvidence From(CharacterEvidenceProfile profile)
=> new(
profile.AgeBand,
profile.Presentation,
profile.HairColour,
profile.SkinTone,
profile.HairLength,
profile.Glasses,
profile.FacialHair,
profile.RelationshipConfidence,
profile.NameConfidence,
profile.PronounConfidence,
profile);
}
private sealed record CharacterCandidate(IllustrationLibraryItem Item, CharacterIllustrationMetadata Metadata, string? PublicUrl)
@ -624,100 +614,21 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
}
}
private sealed record CharacterIllustrationMetadata(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair)
private sealed record CharacterIllustrationMetadata(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair, IllustrationCharacterMetadata Profile)
{
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)
{
}
}
var profile = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);
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";
profile.AgeBand,
profile.Presentation,
profile.HairColour,
profile.SkinTone,
profile.HairLength,
profile.Glasses,
profile.FacialHair,
profile);
}
}

View File

@ -562,6 +562,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
.OrderByDescending(item => item.Weight)
.ToList();
var seen = new HashSet<string>(current.Select(item => $"{item.SourceId}|{item.TargetId}"), StringComparer.OrdinalIgnoreCase);
var inferredKinship = InferredKinshipRelationships(scene, seen);
var memoryRelationships = (storyMemory?.Relationships ?? [])
.Where(item => seen.Add($"{item.SourceId}|{item.TargetId}"))
.Select(item => new StoryIntelligenceExperienceRelationshipState
@ -576,12 +577,65 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
: Math.Max(44, item.Weight - 16)
});
return current.Concat(memoryRelationships)
return current.Concat(inferredKinship).Concat(memoryRelationships)
.OrderByDescending(item => item.Weight)
.Take(4)
.ToList();
}
private static IReadOnlyList<StoryIntelligenceExperienceRelationshipState> InferredKinshipRelationships(SceneIntelligenceScene scene, HashSet<string> seen)
{
var characters = (scene.Characters ?? [])
.Where(character => !string.IsNullOrWhiteSpace(character.Name))
.Select(character => new
{
Name = Clean(character.Name),
Text = $"{character.Name} {character.RoleInScene} {character.Notes} {string.Join(' ', character.Actions ?? [])}".ToLowerInvariant()
})
.DistinctBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var beth = characters.FirstOrDefault(character => string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase));
if (beth is null)
{
return [];
}
var relationships = new List<StoryIntelligenceExperienceRelationshipState>();
foreach (var character in characters.Where(character => !string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase)))
{
var label = character.Text.Contains("sister", StringComparison.OrdinalIgnoreCase) || string.Equals(character.Name, "Grace", StringComparison.OrdinalIgnoreCase)
? "Sister"
: character.Text.Contains("aunt", StringComparison.OrdinalIgnoreCase) || character.Name.Contains("Aunt", StringComparison.OrdinalIgnoreCase)
? "Aunt/Niece"
: character.Text.Contains("mother", StringComparison.OrdinalIgnoreCase) || character.Text.Contains(" mum", StringComparison.OrdinalIgnoreCase)
? "Mother/Daughter"
: string.Empty;
if (string.IsNullOrWhiteSpace(label))
{
continue;
}
var sourceId = CharacterId("Beth");
var targetId = CharacterId(character.Name);
if (!seen.Add($"{sourceId}|{targetId}"))
{
continue;
}
relationships.Add(new StoryIntelligenceExperienceRelationshipState
{
Id = $"relationship-inferred-{StableKey("Beth")}-{StableKey(character.Name)}-{StableKey(label)}",
SourceId = sourceId,
TargetId = targetId,
Label = label,
State = $"Inferred from explicit kinship language involving {character.Name}.",
Weight = 88
});
}
return relationships;
}
private static IReadOnlyList<StoryIntelligenceExperienceTextItem> BuildKnowledge(SceneIntelligenceScene scene)
{
var questions = (scene.QuestionsRaised ?? []).Select(question => new StoryIntelligenceExperienceTextItem
@ -1673,27 +1727,27 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting)
{
var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant();
if (value.Contains("bathroom") || value.Contains("toilet") || value.Contains("washroom")) return "loc-laundry-utility-room";
if (value.Contains("kitchen") || value.Contains("desk") || value.Contains("office") || value.Contains("study") || value.Contains("library")) return "loc-domestic-desk";
if (value.Contains("waiting room") || value.Contains("bedroom") || value.Contains("sitting room") || value.Contains("living room")) return "loc-small-flat-interior";
if (value.Contains("laundry") || value.Contains("utility")) return "loc-laundry-utility-room";
if (value.Contains("stair")) return "loc-narrow-stairwell";
if (value.Contains("car park") || value.Contains("parking")) return "loc-wet-car-park";
if (value.Contains("flat") || value.Contains("apartment")) return "loc-small-flat-interior";
if (value.Contains("house") || value.Contains("estate")) return "loc-estate-house-exterior";
var type = StoryIntelligenceIllustrationCompatibility.LocationType(value);
if (type == "Bathroom") return "loc-laundry-utility-room";
if (type is "Kitchen" or "Bedroom" or "LivingRoom" or "WaitingRoom" or "FlatInterior" or "Hallway") return "loc-small-flat-interior";
if (type == "Office") return "loc-domestic-desk";
if (type == "Stairwell") return "loc-narrow-stairwell";
if (type == "CarPark") return "loc-wet-car-park";
if (type == "HouseExterior") return "loc-estate-house-exterior";
return "loc-domestic-desk";
}
private static string AssetLibraryCode(string name, SceneIntelligenceAsset asset)
{
var value = $"{name} {asset.AssetType} {asset.Status}".ToLowerInvariant();
if (value.Contains("letter")) return "asset-folded-letter";
if (value.Contains("notebook") || value.Contains("diary")) return "asset-worn-notebook";
if (value.Contains("car") || value.Contains("vehicle")) return "asset-red-classic-car";
if (value.Contains("key")) return "asset-old-keys";
if (value.Contains("phone") || value.Contains("record")) return "asset-phone-recorder";
if (value.Contains("photo")) return "asset-photo-print";
if (value.Contains("map")) return "asset-map-pin";
var type = StoryIntelligenceIllustrationCompatibility.AssetType(value);
if (type is "Letter" or "Note" or "Document") return "asset-folded-letter";
if (type is "Notebook" or "Book") return "asset-worn-notebook";
if (type == "Car") return "asset-red-classic-car";
if (type == "Keys") return "asset-old-keys";
if (type == "Telephone") return "asset-phone-recorder";
if (type == "Photograph") return "asset-photo-print";
if (type == "Rucksack") return "asset-rucksack";
return "asset-locked-box";
}

View File

@ -0,0 +1,56 @@
/*
Phase 21K illustration assignment audit fields.
Adds non-breaking persistence columns used by the forensic matcher to explain,
validate and safely invalidate illustration assignments. Existing rows remain
active and are backfilled with conservative defaults.
*/
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'ImportSessionID') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD ImportSessionID int NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'AssignmentStatus') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD AssignmentStatus nvarchar(40) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_AssignmentStatus DEFAULT N'Assigned';
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'AssignedUtc') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD AssignedUtc datetime2(0) NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'LastValidatedUtc') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD LastValidatedUtc datetime2(0) NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'InvalidatedUtc') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD InvalidatedUtc datetime2(0) NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'InvalidationReason') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD InvalidationReason nvarchar(500) NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'EvidenceVersion') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD EvidenceVersion nvarchar(40) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_EvidenceVersion DEFAULT N'21K';
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'IsFallback') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD IsFallback bit NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_IsFallback DEFAULT 0;
EXEC(N'
UPDATE dbo.StoryIntelligenceCharacterIllustrationAssignments
SET AssignedUtc = COALESCE(AssignedUtc, CreatedUtc),
LastValidatedUtc = COALESCE(LastValidatedUtc, UpdatedUtc),
EvidenceVersion = COALESCE(NULLIF(EvidenceVersion, N''''), N''21K''),
AssignmentStatus = COALESCE(NULLIF(AssignmentStatus, N''''), N''Assigned'')
WHERE AssignedUtc IS NULL
OR LastValidatedUtc IS NULL
OR NULLIF(EvidenceVersion, N'''') IS NULL
OR NULLIF(AssignmentStatus, N'''') IS NULL;
');
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_SICharacterIllustrationAssignments_ImportSession' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments'))
BEGIN
EXEC(N'CREATE INDEX IX_SICharacterIllustrationAssignments_ImportSession
ON dbo.StoryIntelligenceCharacterIllustrationAssignments(ImportSessionID, CharacterKey, IsActive)
INCLUDE (IllustrationLibraryItemID, AssignmentStatus, IsFallback, LastValidatedUtc);');
END;
IF COL_LENGTH(N'dbo.StoryIntelligenceIllustrationDemands', N'QueuedUtc') IS NULL
ALTER TABLE dbo.StoryIntelligenceIllustrationDemands ADD QueuedUtc datetime2(0) NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceIllustrationDemands', N'GenerationSource') IS NULL
ALTER TABLE dbo.StoryIntelligenceIllustrationDemands ADD GenerationSource nvarchar(80) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_GenerationSource DEFAULT N'Demand generation';

View File

@ -0,0 +1,119 @@
@model StoryIntelligenceExperiencePrototypeViewModel
@{
ViewData["Title"] = "Story Intelligence Illustration Diagnostics";
var characters = Model.Scenes.SelectMany(scene => scene.Characters.Select(character => new { Scene = scene, Character = character })).ToList();
var locations = Model.Scenes.Select(scene => new { Scene = scene, Location = scene.Location }).ToList();
var assets = Model.Scenes.SelectMany(scene => scene.Assets.Select(asset => new { Scene = scene, Asset = asset })).ToList();
}
<section class="stack-lg">
<div>
<p class="eyebrow">Development diagnostics</p>
<h1>Story Intelligence Illustration Diagnostics</h1>
<p class="muted">Import Session @Model.ImportSessionId · @Model.BookTitle · Snapshot @Model.GeneratedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</p>
</div>
<div class="project-card">
<div class="project-card__body">
<h2>Characters</h2>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Scene</th>
<th>Identity</th>
<th>Illustration</th>
<th>Resolved evidence</th>
<th>Assignment</th>
<th>Demand</th>
<th>Candidate diagnostics</th>
</tr>
</thead>
<tbody>
@foreach (var row in characters)
{
<tr>
<td>@row.Scene.SceneNumber</td>
<td><strong>@row.Character.Name</strong><br /><span class="muted">@row.Character.Id</span></td>
<td><code>@(row.Character.ImageResolution.StableCode ?? row.Character.LibraryCode)</code><br />@row.Character.ImageResolution.Status</td>
<td>Age @Display(row.Character.ImageResolution.ResolvedAgeBand)<br />Presentation @Display(row.Character.ImageResolution.ResolvedPresentation)</td>
<td>@Display(row.Character.ImageResolution.ReasonChosen)<br /><span class="muted">@Display(row.Character.ImageResolution.IllustrationAllocationStatus)</span></td>
<td>@Display(row.Character.ImageResolution.DemandStatus)</td>
<td><pre class="small mb-0">@Display(row.Character.ImageResolution.RejectedCandidates)</pre></td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="project-card">
<div class="project-card__body">
<h2>Locations</h2>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Scene</th>
<th>Location</th>
<th>Semantic type</th>
<th>Illustration</th>
<th>Fallback</th>
</tr>
</thead>
<tbody>
@foreach (var row in locations)
{
var type = StoryIntelligenceIllustrationCompatibility.LocationType($"{row.Location.Name} {row.Location.Label}");
<tr>
<td>@row.Scene.SceneNumber</td>
<td><strong>@row.Location.Name</strong><br /><span class="muted">@row.Location.Id</span></td>
<td>@type</td>
<td><code>@row.Location.LibraryCode</code><br />@row.Location.ImageResolution.Status</td>
<td>@row.Location.ImageResolution.FallbackUsed</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="project-card">
<div class="project-card__body">
<h2>Assets</h2>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Scene</th>
<th>Asset</th>
<th>Semantic type</th>
<th>Illustration</th>
<th>Fallback</th>
</tr>
</thead>
<tbody>
@foreach (var row in assets)
{
var type = StoryIntelligenceIllustrationCompatibility.AssetType($"{row.Asset.Name} {row.Asset.Label}");
<tr>
<td>@row.Scene.SceneNumber</td>
<td><strong>@row.Asset.Name</strong><br /><span class="muted">@row.Asset.Id</span></td>
<td>@type</td>
<td><code>@row.Asset.LibraryCode</code><br />@row.Asset.ImageResolution.Status</td>
<td>@row.Asset.ImageResolution.FallbackUsed</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</section>
@functions {
private static string Display(string? value)
=> string.IsNullOrWhiteSpace(value) ? "none" : value;
}