Phase 21M - Deterministic Illustration Assignment Repair

This commit is contained in:
Nick Beckley 2026-07-13 21:15:49 +00:00
parent 948ae2e54d
commit 7001359281
7 changed files with 201 additions and 19 deletions

View File

@ -47,7 +47,8 @@ var tests = new (string Name, Action Test)[]
("Illustration matching rejects duplicate named portrait allocation", IllustrationMatchingRejectsDuplicateNamedPortraitAllocation), ("Illustration matching rejects duplicate named portrait allocation", IllustrationMatchingRejectsDuplicateNamedPortraitAllocation),
("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches), ("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches),
("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate), ("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate),
("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage) ("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage),
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes)
}; };
foreach (var test in tests) foreach (var test in tests)
@ -347,13 +348,13 @@ static void IllustrationPromptBuilderSeparatesSpecAndPrompt()
var result = builder.Build(spec); var result = builder.Build(spec);
Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded."); Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded.");
Assert(result.TemplateVersion == "21D.2", "Illustration prompt template version should reflect the vivid art-direction reset."); Assert(result.TemplateVersion == "21J.3", "Illustration prompt template version should reflect the current approved art direction.");
Assert(result.Prompt.Contains("no visible text", StringComparison.OrdinalIgnoreCase), "Prompt did not include visual safety guidance."); Assert(result.Prompt.Contains("no visible text", StringComparison.OrdinalIgnoreCase), "Prompt did not include visual safety guidance.");
Assert(result.Prompt.Contains("Category composition rules", StringComparison.Ordinal), "Prompt did not include category composition rules."); Assert(result.Prompt.Contains("Category composition rules", StringComparison.Ordinal), "Prompt did not include category composition rules.");
Assert(result.Prompt.Contains("Structured details", StringComparison.Ordinal), "Prompt did not include structured details."); Assert(result.Prompt.Contains("Structured details", StringComparison.Ordinal), "Prompt did not include structured details.");
Assert(result.Prompt.Contains("modern cinematic digital illustration", StringComparison.OrdinalIgnoreCase), "Prompt did not include the new cinematic digital art direction."); Assert(result.Prompt.Contains("modern cinematic digital illustration", StringComparison.OrdinalIgnoreCase), "Prompt did not include the new cinematic digital art direction.");
Assert(result.Prompt.Contains("premium narrative game concept art", StringComparison.OrdinalIgnoreCase), "Prompt did not include premium game concept-art guidance."); Assert(result.Prompt.Contains("premium narrative game concept art", StringComparison.OrdinalIgnoreCase), "Prompt did not include premium game concept-art guidance.");
Assert(result.Prompt.Contains("bright balanced lighting", StringComparison.OrdinalIgnoreCase), "Prompt did not include brighter lighting guidance."); Assert(result.Prompt.Contains("balanced cinematic lighting", StringComparison.OrdinalIgnoreCase), "Prompt did not include current lighting guidance.");
Assert(result.Prompt.Contains("avoid muddy olive and brown grading", StringComparison.OrdinalIgnoreCase), "Prompt did not explicitly avoid the previous muddy grading failure mode."); Assert(result.Prompt.Contains("avoid muddy olive and brown grading", StringComparison.OrdinalIgnoreCase), "Prompt did not explicitly avoid the previous muddy grading failure mode.");
Assert(result.Prompt.Contains(spec.Code, StringComparison.Ordinal), "Prompt did not include stable code."); Assert(result.Prompt.Contains(spec.Code, StringComparison.Ordinal), "Prompt did not include stable code.");
Assert(result.SpecificationJson.Contains("\"code\": \"asset-folded-letter\"", StringComparison.Ordinal), "Specification JSON did not preserve the stable code."); Assert(result.SpecificationJson.Contains("\"code\": \"asset-folded-letter\"", StringComparison.Ordinal), "Specification JSON did not preserve the stable code.");
@ -560,6 +561,40 @@ static void IllustrationDemandArchetypesStayBroadAndUseActiveTemplate()
Assert(IllustrationPromptBuilder.CurrentTemplateVersion == "21J.3", $"Demand generation should use the active shared template, got {IllustrationPromptBuilder.CurrentTemplateVersion}."); Assert(IllustrationPromptBuilder.CurrentTemplateVersion == "21J.3", $"Demand generation should use the active shared template, got {IllustrationPromptBuilder.CurrentTemplateVersion}.");
} }
static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes()
{
var femaleInstructor = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("female instructor", ["female instructor"], []);
var maleExaminers = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("younger male examiners", ["younger male examiners"], []);
var lad = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("tubby lad", ["tubby lad"], []);
var girl = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("girl", ["schoolgirl"], []);
var kevin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Kevin", ["Kevin", "female instructor (Kevin's)"], ["female instructor", "Kevin's examiner"]);
Assert(femaleInstructor.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"female instructor should be Feminine, got {femaleInstructor.Presentation}.");
Assert(maleExaminers.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"male examiners should be Masculine, got {maleExaminers.Presentation}.");
Assert(lad.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"lad should be Masculine, got {lad.Presentation}.");
Assert(lad.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"lad should infer a young age band, got {lad.AgeBand}.");
Assert(girl.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"girl should be Feminine, got {girl.Presentation}.");
Assert(girl.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"schoolgirl should infer Child, got {girl.AgeBand}.");
Assert(kevin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Kevin should not inherit feminine presentation from his instructor, got {kevin.Presentation}.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("social workers"), "Social workers should be treated as a group entity.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("younger male examiners"), "Plural examiners should be treated as a group entity.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("uniformed people"), "Uniformed people should be treated as a group entity.");
Assert(!StoryIntelligenceIllustrationCompatibility.IsGroupEntity("social worker"), "Singular social worker should remain an individual entity.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "LaundryUtilityRoom"), "Bathroom must not match laundry room.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Road must not match car park.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not match flat interior.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Ford Fiesta") == "Car", "Ford Fiesta should classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Car") == "Car", "Car should classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("supercharger") == "CarPart", "Supercharger should not classify as full car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("biscuit tin") == "Tin", "Biscuit tin should not classify as Photograph.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not classify as Letter.");
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is fifteen."], []);
var adult = Metadata("char-family-relative", "MatureAdult", "Feminine");
Assert(Rejected(beth, adult).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Existing adult assignment must be discarded when Beth evidence says YoungTeen.");
}
static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false) static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false)
=> new( => new(
ageBand, ageBand,

View File

@ -48,11 +48,12 @@ public static class StoryIntelligenceIllustrationCompatibility
public static CharacterEvidenceProfile InferCharacterEvidence(string name, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues) public static CharacterEvidenceProfile InferCharacterEvidence(string name, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues)
{ {
var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} "; var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var relationshipText = $" {string.Join(' ', relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} "; var identityScopedText = $" {string.Join(". ", identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var relationshipText = $" {string.Join(". ", relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var nameText = $" {name.ToLowerInvariant()} "; var nameText = $" {name.ToLowerInvariant()} ";
var entityText = $"{nameText} {identityText}"; var entityText = $"{nameText} {identityText}";
var scopedText = EntityScopedText(entityText, name); var scopedText = EntityScopedText($"{nameText} {identityScopedText}", name);
var relationshipSafeText = $"{nameText} {relationshipText}"; var relationshipSafeText = RelationshipScopedText(relationshipText, name);
var titlePresentation = TitlePresentation(scopedText); var titlePresentation = TitlePresentation(scopedText);
var relationshipPresentation = RelationshipPresentation(relationshipSafeText); var relationshipPresentation = RelationshipPresentation(relationshipSafeText);
var entityPresentation = RelationshipPresentation(scopedText); var entityPresentation = RelationshipPresentation(scopedText);
@ -251,6 +252,7 @@ public static class StoryIntelligenceIllustrationCompatibility
public static string AssetType(string? value) public static string AssetType(string? value)
{ {
var text = (value ?? string.Empty).ToLowerInvariant(); var text = (value ?? string.Empty).ToLowerInvariant();
var padded = $" {text} ";
if (text.Contains("ambulance", StringComparison.OrdinalIgnoreCase)) return "Ambulance"; if (text.Contains("ambulance", StringComparison.OrdinalIgnoreCase)) return "Ambulance";
if (text.Contains("stretcher", StringComparison.OrdinalIgnoreCase)) return "Stretcher"; if (text.Contains("stretcher", StringComparison.OrdinalIgnoreCase)) return "Stretcher";
if (text.Contains("body bag", StringComparison.OrdinalIgnoreCase)) return "BodyBag"; if (text.Contains("body bag", StringComparison.OrdinalIgnoreCase)) return "BodyBag";
@ -260,6 +262,10 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "driving licence", "driver licence", "license", "licence")) return "Document"; if (ContainsAny(text, "driving licence", "driver licence", "license", "licence")) return "Document";
if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery"; if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery";
if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon"; if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon";
if (ContainsAny(padded, " ford fiesta ", " fiesta ", " motor car ", " car ", " vehicle ")) return "Car";
if (ContainsAny(text, "supercharger", "engine part")) return "CarPart";
if (ContainsAny(text, "biscuit tin", "tin")) return "Tin";
if (ContainsAny(text, "parcel", "package")) return "Parcel";
if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack"; if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack";
if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase"; if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase";
if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note"; if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note";
@ -268,7 +274,7 @@ public static class StoryIntelligenceIllustrationCompatibility
if (text.Contains("book", StringComparison.OrdinalIgnoreCase)) return "Book"; if (text.Contains("book", StringComparison.OrdinalIgnoreCase)) return "Book";
if (ContainsAny(text, "telephone", "phone", "recorder")) return "Telephone"; if (ContainsAny(text, "telephone", "phone", "recorder")) return "Telephone";
if (text.Contains("key", StringComparison.OrdinalIgnoreCase)) return "Keys"; if (text.Contains("key", StringComparison.OrdinalIgnoreCase)) return "Keys";
if (ContainsAny(text, "tr6", " car ", "vehicle")) return "Car"; if (ContainsAny(text, "tr6")) return "Car";
if (ContainsAny(text, "photo", "photograph")) return "Photograph"; if (ContainsAny(text, "photo", "photograph")) return "Photograph";
if (ContainsAny(text, "document", "paper")) return "Document"; if (ContainsAny(text, "document", "paper")) return "Document";
return "UnknownObject"; return "UnknownObject";
@ -277,7 +283,7 @@ public static class StoryIntelligenceIllustrationCompatibility
public static bool LocationCompatible(string requestedType, string candidateType) public static bool LocationCompatible(string requestedType, string candidateType)
=> requestedType == candidateType => requestedType == candidateType
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior") || (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" or "WaitingRoom" && candidateType is "FlatInterior") || (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior")
|| (requestedType == "Bathroom" && candidateType == "Bathroom"); || (requestedType == "Bathroom" && candidateType == "Bathroom");
public static bool AssetCompatible(string requestedType, string candidateType) public static bool AssetCompatible(string requestedType, string candidateType)
@ -285,12 +291,29 @@ public static class StoryIntelligenceIllustrationCompatibility
|| (requestedType == "Note" && candidateType is "Letter" or "Document") || (requestedType == "Note" && candidateType is "Letter" or "Document")
|| (requestedType == "Rucksack" && candidateType == "Rucksack"); || (requestedType == "Rucksack" && candidateType == "Rucksack");
public static bool IsGroupEntity(string? value)
{
var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
return clean.EndsWith('s')
&& (clean.Contains("social worker", StringComparison.Ordinal)
|| clean.Contains("neighbour", StringComparison.Ordinal)
|| clean.Contains("neighbor", StringComparison.Ordinal)
|| clean.Contains("police officer", StringComparison.Ordinal)
|| clean.Contains("student", StringComparison.Ordinal)
|| clean.Contains("examiner", StringComparison.Ordinal)
|| clean.Contains("uniformed", StringComparison.Ordinal)
|| clean.Contains("responder", StringComparison.Ordinal))
|| clean is "crowd" or "uniformed people" or "police" or "social services";
}
private static EvidenceSignal ExplicitAgeBand(string text) private static EvidenceSignal ExplicitAgeBand(string text)
{ {
if (ContainsAny(text, "15", "fifteen")) return new(AgeBands.YoungTeen, 0.98m); 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, "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, "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, "toddler", "baby", "infant", "newborn")) return new(AgeBands.Child, 0.98m);
if (ContainsAny(text, "child", "young girl", "young boy", "schoolboy", "schoolgirl", " boy ", " girl ", " lad ")) return new(AgeBands.Child, 0.86m);
if (ContainsAny(text, "teenager", "teenage")) return new(AgeBands.OlderTeen, 0.86m);
if (ContainsAny(text, "young adult", "young woman", "young man")) return new(AgeBands.YoungAdult, 0.82m); 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, "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); if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m);
@ -327,8 +350,8 @@ public static class StoryIntelligenceIllustrationCompatibility
private static EvidenceSignal RelationshipPresentation(string text) private static EvidenceSignal RelationshipPresentation(string text)
{ {
if (ContainsAny(text, "mother", " mum", "mummy", "aunt", "sister", "daughter", "wife", "grandmother", " niece", "woman", "girl", "lady", "queen")) return new(Presentations.Feminine, 0.92m); if (ContainsAny(text, "female", "mother", " mum", "mummy", "aunt", "sister", "daughter", "wife", "widow", "grandmother", " niece", "woman", "girl", "lady", "queen")) return new(Presentations.Feminine, 0.92m);
if (ContainsAny(text, "father", " dad", "daddy", "uncle", "brother", "son", "husband", "grandfather", " nephew", "man", "boy", "lad", "king")) return new(Presentations.Masculine, 0.92m); if (ContainsAny(text, "male", "gentleman", "father", " dad", "daddy", "uncle", "brother", "son", "husband", "widower", "grandfather", " nephew", "man", "boy", "lad", "bloke", "chap", "king")) return new(Presentations.Masculine, 0.92m);
return EvidenceSignal.Unknown; return EvidenceSignal.Unknown;
} }
@ -413,10 +436,39 @@ public static class StoryIntelligenceIllustrationCompatibility
} }
var sentences = text.Split(['.', '!', '?', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var sentences = text.Split(['.', '!', '?', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var possessiveAscii = $"{cleanName}'s";
var possessiveCurly = $"{cleanName}\u2019s";
var scoped = sentences.Where(sentence => sentence.Contains(cleanName, StringComparison.OrdinalIgnoreCase)).ToList(); var scoped = sentences.Where(sentence => sentence.Contains(cleanName, StringComparison.OrdinalIgnoreCase)).ToList();
scoped = scoped
.Where(sentence => !sentence.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
&& !sentence.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
.ToList();
return scoped.Count == 0 ? $" {cleanName} " : $" {string.Join(' ', scoped)} "; return scoped.Count == 0 ? $" {cleanName} " : $" {string.Join(' ', scoped)} ";
} }
private static string RelationshipScopedText(string text, string entityName)
{
var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(cleanName))
{
return text;
}
var possessiveAscii = $"{cleanName}'s";
var possessiveCurly = $"{cleanName}\u2019s";
if (text.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
|| text.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
{
return string.Empty;
}
var fragments = text.Split(['.', '!', '?', ';', ',', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(fragment => !fragment.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
&& !fragment.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
.ToList();
return fragments.Count == 0 ? string.Empty : $" {string.Join(' ', fragments)} ";
}
private static bool AttributeNearEntity(string sentence, string cleanName) private static bool AttributeNearEntity(string sentence, string cleanName)
{ {
var lower = sentence.ToLowerInvariant(); var lower = sentence.ToLowerInvariant();

View File

@ -68,6 +68,12 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
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))
{ {
if (StoryIntelligenceIllustrationCompatibility.IsGroupEntity(observation.Name))
{
ApplyGroupDiagnostics(prototype, observation);
continue;
}
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)) if (ownExistingItemId.HasValue && duplicateExistingItemIds.Contains(ownExistingItemId.Value) && !retainedExistingItemIds.Add(ownExistingItemId.Value))
@ -119,10 +125,12 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
var reasonReassigned = existingScore is not null && chosen.Candidate.Item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID var reasonReassigned = existingScore is not null && chosen.Candidate.Item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID
? ReassignmentReason(observation, existingScore, chosen) ? ReassignmentReason(observation, existingScore, chosen)
: existing is { IllustrationLibraryItemID: null }
? $"Assigned {chosen.Candidate.Item.StableCode} because generated demand or newly valid library art replaced a fallback assignment."
: null; : null;
var reason = reasonReassigned ?? (existing is null var reason = reasonReassigned ?? (existing is null
? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence." ? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence."
: "Preserved existing Story Intelligence character illustration assignment."); : $"Retained {chosen.Candidate.Item.StableCode}; existing assignment passed hard revalidation with score {chosen.Score:N0}.");
var candidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions); var candidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
var evidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions); var evidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions);
@ -140,7 +148,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
reasonReassigned)); reasonReassigned));
assignedItemIds.Add(chosen.Candidate.Item.IllustrationLibraryItemID); assignedItemIds.Add(chosen.Candidate.Item.IllustrationLibraryItemID);
ApplyAssignment(prototype, observation, chosen, saved, reason, reasonReassigned, candidateDiagnostics); ApplyAssignment(prototype, observation, existing, chosen, saved, reason, reasonReassigned, evidenceJson, candidateDiagnostics);
} }
} }
@ -159,7 +167,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
if (best is null) if (best is null)
{ {
return existingScore; return existingScore.Score >= SuitableScore ? existingScore : null;
} }
var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence); var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence);
@ -170,7 +178,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin) if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin)
{ {
return existingScore; return existingScore.Score >= SuitableScore ? existingScore : best;
} }
} }
@ -441,10 +449,12 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
private void ApplyAssignment( private void ApplyAssignment(
StoryIntelligenceExperiencePrototypeViewModel prototype, StoryIntelligenceExperiencePrototypeViewModel prototype,
CharacterObservation observation, CharacterObservation observation,
StoryIntelligenceCharacterIllustrationAssignment? existing,
MatchScore chosen, MatchScore chosen,
StoryIntelligenceCharacterIllustrationAssignment saved, StoryIntelligenceCharacterIllustrationAssignment saved,
string reason, string reason,
string? reasonReassigned, string? reasonReassigned,
string evidenceJson,
string candidateDiagnostics) string candidateDiagnostics)
{ {
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)))
@ -468,6 +478,10 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
RejectedCandidates = candidateDiagnostics, RejectedCandidates = candidateDiagnostics,
ReasonChosen = reason, ReasonChosen = reason,
ReasonReassigned = reasonReassigned, ReasonReassigned = reasonReassigned,
PreviousIllustration = existing?.StableCode,
PreviousEvidence = existing?.EvidenceJson,
CurrentEvidence = evidenceJson,
DemandKey = DemandKey(observation.Evidence),
DemandStatus = "Satisfied", DemandStatus = "Satisfied",
ResolvedPresentation = observation.Evidence.Presentation, ResolvedPresentation = observation.Evidence.Presentation,
ResolvedAgeBand = observation.Evidence.AgeBand, ResolvedAgeBand = observation.Evidence.AgeBand,
@ -506,6 +520,8 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
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.",
CurrentEvidence = JsonSerializer.Serialize(observation.Evidence, JsonOptions),
DemandKey = DemandKey(observation.Evidence),
DemandStatus = demandStatus, DemandStatus = demandStatus,
ResolvedPresentation = observation.Evidence.Presentation, ResolvedPresentation = observation.Evidence.Presentation,
ResolvedAgeBand = observation.Evidence.AgeBand, ResolvedAgeBand = observation.Evidence.AgeBand,
@ -552,6 +568,31 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|| clean.StartsWith("unidentified ", StringComparison.Ordinal); || clean.StartsWith("unidentified ", StringComparison.Ordinal);
} }
private static void ApplyGroupDiagnostics(StoryIntelligenceExperiencePrototypeViewModel prototype, CharacterObservation observation)
{
foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase)))
{
character.LibraryCode = string.Empty;
character.ImagePath = "/images/story-intelligence/prototype/character-group.svg";
character.ImageResolution = new StoryIntelligenceExperienceImageResolution
{
EntityId = character.Id,
StableCode = string.Empty,
IllustrationLibraryItemId = null,
Status = "GroupEntity",
FinalImageUrl = null,
FallbackImageUrl = character.ImagePath,
FallbackUsed = true,
ReasonChosen = "Collective group entity; reusable group fallback used without unique portrait assignment or demand.",
CurrentEvidence = JsonSerializer.Serialize(observation.Evidence, JsonOptions),
DemandStatus = "Not required for group entity",
ResolvedPresentation = "Unknown",
ResolvedAgeBand = "Unknown",
IllustrationAllocationStatus = "Group entity"
};
}
}
private static string NormaliseUnknown(string? value, string fallback) private static string NormaliseUnknown(string? value, string fallback)
=> IsUnknown(value) ? fallback : value!; => IsUnknown(value) ? fallback : value!;

View File

@ -1760,11 +1760,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{ {
var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant(); var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant();
var type = StoryIntelligenceIllustrationCompatibility.LocationType(value); var type = StoryIntelligenceIllustrationCompatibility.LocationType(value);
if (type == "Bathroom") return "loc-laundry-utility-room"; if (type is "Bathroom" or "WaitingRoom" or "Road") return string.Empty;
if (type is "Kitchen" or "Bedroom" or "LivingRoom" or "WaitingRoom" or "FlatInterior" or "Hallway") return "loc-small-flat-interior"; if (type is "Kitchen" or "Bedroom" or "LivingRoom" or "FlatInterior" or "Hallway") return "loc-small-flat-interior";
if (type == "Office") return "loc-domestic-desk"; if (type == "Office") return "loc-domestic-desk";
if (type == "Stairwell") return "loc-narrow-stairwell"; if (type == "Stairwell") return "loc-narrow-stairwell";
if (type is "CarPark" or "Road") return "loc-wet-car-park"; if (type is "CarPark") return "loc-wet-car-park";
if (type == "HouseExterior") return "loc-estate-house-exterior"; if (type == "HouseExterior") return "loc-estate-house-exterior";
return string.Empty; return string.Empty;
} }

View File

@ -151,6 +151,10 @@ public sealed class StoryIntelligenceExperienceImageResolution
public string? RejectedCandidates { get; init; } public string? RejectedCandidates { get; init; }
public string? ReasonChosen { get; init; } public string? ReasonChosen { get; init; }
public string? ReasonReassigned { get; init; } public string? ReasonReassigned { get; init; }
public string? PreviousIllustration { get; init; }
public string? PreviousEvidence { get; init; }
public string? CurrentEvidence { get; init; }
public string? DemandKey { get; init; }
public string? DemandStatus { get; init; } public string? DemandStatus { get; init; }
public string? ResolvedPresentation { get; init; } public string? ResolvedPresentation { get; init; }
public string? ResolvedAgeBand { get; init; } public string? ResolvedAgeBand { get; init; }

View File

@ -24,8 +24,11 @@
<th>Identity</th> <th>Identity</th>
<th>Illustration</th> <th>Illustration</th>
<th>Resolved evidence</th> <th>Resolved evidence</th>
<th>Revalidation</th>
<th>Assignment</th> <th>Assignment</th>
<th>Previous / change</th>
<th>Demand</th> <th>Demand</th>
<th>Demand key</th>
<th>Candidate diagnostics</th> <th>Candidate diagnostics</th>
</tr> </tr>
</thead> </thead>
@ -37,8 +40,19 @@
<td><strong>@row.Character.Name</strong><br /><span class="muted">@row.Character.Id</span></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><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>Age @Display(row.Character.ImageResolution.ResolvedAgeBand)<br />Presentation @Display(row.Character.ImageResolution.ResolvedPresentation)</td>
<td>@Revalidation(row.Character.ImageResolution)</td>
<td>@Display(row.Character.ImageResolution.ReasonChosen)<br /><span class="muted">@Display(row.Character.ImageResolution.IllustrationAllocationStatus)</span></td> <td>@Display(row.Character.ImageResolution.ReasonChosen)<br /><span class="muted">@Display(row.Character.ImageResolution.IllustrationAllocationStatus)</span></td>
<td>
Previous illustration: <code>@Display(row.Character.ImageResolution.PreviousIllustration)</code><br />
Change: @Display(row.Character.ImageResolution.ReasonReassigned)<br />
<details>
<summary>Evidence</summary>
<pre class="small mb-0">Previous: @Display(row.Character.ImageResolution.PreviousEvidence)
Current: @Display(row.Character.ImageResolution.CurrentEvidence)</pre>
</details>
</td>
<td>@Display(row.Character.ImageResolution.DemandStatus)</td> <td>@Display(row.Character.ImageResolution.DemandStatus)</td>
<td><code>@Display(row.Character.ImageResolution.DemandKey)</code></td>
<td><pre class="small mb-0">@Display(row.Character.ImageResolution.RejectedCandidates)</pre></td> <td><pre class="small mb-0">@Display(row.Character.ImageResolution.RejectedCandidates)</pre></td>
</tr> </tr>
} }
@ -116,4 +130,20 @@
@functions { @functions {
private static string Display(string? value) private static string Display(string? value)
=> string.IsNullOrWhiteSpace(value) ? "none" : value; => string.IsNullOrWhiteSpace(value) ? "none" : value;
private static string Revalidation(StoryIntelligenceExperienceImageResolution resolution)
{
if (string.Equals(resolution.Status, "GroupEntity", StringComparison.OrdinalIgnoreCase))
{
return "Group entity";
}
if (resolution.FallbackUsed)
{
return "No compatible illustration";
}
return string.IsNullOrWhiteSpace(resolution.ReasonReassigned) ? "Passed" : "Reassigned";
}
} }

View File

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Group of people">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#18283d"/>
<stop offset="1" stop-color="#07131f"/>
</linearGradient>
<linearGradient id="ring" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#8fb9df"/>
<stop offset="1" stop-color="#d9a85f"/>
</linearGradient>
</defs>
<rect width="240" height="240" rx="120" fill="url(#bg)"/>
<circle cx="120" cy="120" r="86" fill="none" stroke="url(#ring)" stroke-width="6" opacity=".85"/>
<circle cx="120" cy="92" r="26" fill="#d8e5f1" opacity=".54"/>
<path d="M74 164c8-31 28-48 46-48s38 17 46 48" fill="#d8e5f1" opacity=".32"/>
<circle cx="76" cy="112" r="20" fill="#8fb9df" opacity=".38"/>
<path d="M42 166c7-25 21-38 34-38 11 0 22 8 29 24" fill="#8fb9df" opacity=".2"/>
<circle cx="164" cy="112" r="20" fill="#d9a85f" opacity=".42"/>
<path d="M135 152c7-16 18-24 29-24 13 0 27 13 34 38" fill="#d9a85f" opacity=".2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB