Phase 21K - Forensic Illustration Matching Repair
This commit is contained in:
parent
ca73c9e6f3
commit
2d2055f14a
@ -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();
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
405
PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs
Normal file
405
PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs
Normal 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);
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
@ -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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user