using System.Text.Json; using System.Text.RegularExpressions; using System.Security.Cryptography; using System.Text; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IStoryIntelligenceIllustrationMatchingService { Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype); Task ApplyPersistedCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype); Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype); } public sealed class StoryIntelligenceIllustrationMatchingService( IIllustrationLibraryRepository library, IStoryIntelligenceIllustrationMatchingRepository assignments, IIllustrationPromptBuilder promptBuilder, IUploadStorageService uploadStorage, ILogger logger) : IStoryIntelligenceIllustrationMatchingService { private const int SuitableScore = 48; private const int ReassignmentMargin = 24; private const int DemandGenerationThreshold = 1; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; public async Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype) { if (projectId <= 0 || prototype.Scenes.Count == 0) { return; } var catalogue = (await library.ListAsync(new IllustrationLibraryFilter { Category = IllustrationLibraryCategories.Character })) .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) .Select(CharacterCandidate.From) .Where(candidate => candidate is not null) .Cast() .Select(candidate => candidate with { PublicUrl = ResolveExistingPublicUploadUrl(candidate.Item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.Item.FilePath) }) .Where(candidate => !string.IsNullOrWhiteSpace(candidate.PublicUrl)) .ToList(); if (catalogue.Count == 0) { logger.LogWarning("No generated character illustrations are available for Story Intelligence matching."); return; } var existingAssignments = (await assignments.ListAssignmentsAsync(projectId)) .ToDictionary(item => item.CharacterKey, StringComparer.OrdinalIgnoreCase); var assignedItemIds = existingAssignments.Values .Where(item => item.IllustrationLibraryItemID.HasValue) .Select(item => item.IllustrationLibraryItemID!.Value) .ToHashSet(); var duplicateExistingItemIds = existingAssignments.Values .Where(item => item.IllustrationLibraryItemID.HasValue) .GroupBy(item => item.IllustrationLibraryItemID!.Value) .Where(group => group.Select(item => item.CharacterKey).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1) .Select(group => group.Key) .ToHashSet(); var retainedExistingItemIds = new HashSet(); var observations = BuildCharacterObservations(prototype); 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 ownExistingItemId = existing?.IllustrationLibraryItemID; if (ownExistingItemId.HasValue && duplicateExistingItemIds.Contains(ownExistingItemId.Value) && !retainedExistingItemIds.Add(ownExistingItemId.Value)) { ownExistingItemId = null; existing = null; } var allScores = catalogue .Select(candidate => Score( candidate, observation, assignedItemIds.Contains(candidate.Item.IllustrationLibraryItemID) && candidate.Item.IllustrationLibraryItemID != ownExistingItemId)) .OrderByDescending(score => score.Score) .ThenBy(score => score.IsAlreadyAssignedToAnotherSignificantCharacter) .ThenByDescending(score => score.Candidate.Item.Status == IllustrationLibraryStatuses.Approved) .ThenByDescending(score => score.Candidate.Item.UpdatedUtc) .ToList(); var existingScore = existing?.IllustrationLibraryItemID is int existingItemId ? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId) : null; var best = allScores .Where(score => score.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 = EvidenceTraceJson(observation, existing); 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; } var reasonReassigned = existingScore is not null && chosen.Candidate.Item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID ? 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; var reason = reasonReassigned ?? (existing is null ? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence." : $"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 evidenceJson = EvidenceTraceJson(observation, existing); var saved = await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave( projectId, observation.CharacterKey, observation.Name, chosen.Candidate.Item.IllustrationLibraryItemID, chosen.Candidate.Item.StableCode, Confidence(chosen.Score), chosen.Score, evidenceJson, candidateDiagnostics, reason, reasonReassigned)); assignedItemIds.Add(chosen.Candidate.Item.IllustrationLibraryItemID); ApplyAssignment(prototype, observation, existing, chosen, saved, reason, reasonReassigned, evidenceJson, candidateDiagnostics); } } public async Task ApplyPersistedCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype) { if (projectId <= 0 || prototype.Scenes.Count == 0) { return; } var existingAssignments = (await assignments.ListAssignmentsAsync(projectId)) .Where(item => item.IllustrationLibraryItemID.HasValue && !string.IsNullOrWhiteSpace(item.StableCode)) .ToDictionary(item => item.CharacterKey, StringComparer.OrdinalIgnoreCase); if (existingAssignments.Count == 0) { return; } var catalogue = (await library.ListAsync(new IllustrationLibraryFilter { Category = IllustrationLibraryCategories.Character })) .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) .Where(item => !string.IsNullOrWhiteSpace(item.StableCode)) .GroupBy(item => item.IllustrationLibraryItemID) .ToDictionary(group => group.Key, group => group.First()); foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters)) { if (!existingAssignments.TryGetValue(character.Id, out var assignment) || assignment.IllustrationLibraryItemID is not int itemId || !catalogue.TryGetValue(itemId, out var item)) { continue; } var publicUrl = ResolveExistingPublicUploadUrl(item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(item.FilePath); if (string.IsNullOrWhiteSpace(publicUrl)) { continue; } var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl) ? character.ImagePath : character.ImageResolution.FallbackImageUrl; character.LibraryCode = item.StableCode; character.ImagePath = publicUrl; character.ImageResolution = new StoryIntelligenceExperienceImageResolution { EntityId = character.Id, StableCode = item.StableCode, IllustrationLibraryItemId = item.IllustrationLibraryItemID, Status = item.Status, FinalImageUrl = publicUrl, FallbackImageUrl = fallbackImageUrl, FallbackUsed = false, AssignmentConfidence = assignment.MatchConfidence, MatchingScore = assignment.MatchingScore, ReasonChosen = FirstConfigured(assignment.ReasonChosen, "Replay used persisted character illustration assignment."), ReasonReassigned = assignment.ReasonReassigned, PreviousIllustration = assignment.StableCode, DemandStatus = "Replay read-only; demand generation disabled", IllustrationAllocationStatus = "Persisted replay assignment" }; } } public async Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype) { if (projectId <= 0 || prototype.Scenes.Count == 0) { return; } var items = await library.ListAsync(new IllustrationLibraryFilter()); var lookup = items .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) .GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); foreach (var location in prototype.Scenes.Select(scene => scene.Location)) { await ApplyLocationDemandAsync(projectId, location, lookup); } foreach (var asset in prototype.Scenes.SelectMany(scene => scene.Assets)) { await ApplyAssetDemandAsync(projectId, asset, lookup); } } private async Task ApplyLocationDemandAsync( int projectId, StoryIntelligenceExperienceLocationState location, IReadOnlyDictionary> lookup) { var type = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.Name} {location.Label}"); if (type is "UnknownInterior") { location.ImageResolution.ReasonChosen ??= "Location type is unresolved; neutral location fallback retained and no finished unrelated artwork assigned."; location.ImageResolution.DemandStatus ??= "Not queued until location type resolves"; return; } if (!location.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(location.ImageResolution.FinalImageUrl)) { return; } var demandKey = $"location|{type}".ToLowerInvariant(); var code = $"loc-demand-{StableHash(demandKey)}"; location.LibraryCode = code; if (TryApplyGeneratedSupportingImage(location.Id, code, location.ImagePath, lookup, out var resolution)) { location.ImageResolution = WithDemandDiagnostics( resolution, demandKey, "Satisfied by generated demand illustration", $"Applied generated {type} location illustration from demand key {demandKey}."); location.ImagePath = resolution.FinalImageUrl ?? location.ImagePath; return; } var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest( projectId, demandKey, "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown")); var queued = await QueueDemandSpecAsync(LocationDemandSpecification(code, demandKey, type)); location.ImageResolution = WithDemandDiagnostics( location.ImageResolution, demandKey, queued ? $"Queued missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})" : $"Demand recorded for missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})", $"No generated compatible {type} location illustration exists; neutral typed fallback retained while reusable demand is generated.", code, queued ? "DemandQueued" : "DemandRecorded"); } private async Task ApplyAssetDemandAsync( int projectId, StoryIntelligenceExperienceAssetState asset, IReadOnlyDictionary> lookup) { var evidenceText = $"{asset.Name} {asset.Label}"; var type = StoryIntelligenceIllustrationCompatibility.AssetType(evidenceText); if (type is "UnknownObject") { asset.ImageResolution.ReasonChosen ??= "Asset type is unresolved; neutral asset fallback retained and no unrelated finished artwork assigned."; asset.ImageResolution.DemandStatus ??= "Not queued until asset type resolves"; return; } if (!asset.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(asset.ImageResolution.FinalImageUrl)) { return; } var colour = StoryIntelligenceIllustrationCompatibility.AssetColour(evidenceText); var demandKey = $"asset|{type}|{colour}".ToLowerInvariant(); var code = $"asset-demand-{StableHash(demandKey)}"; asset.LibraryCode = code; if (TryApplyGeneratedSupportingImage(asset.Id, code, asset.ImagePath, lookup, out var resolution)) { asset.ImageResolution = WithDemandDiagnostics( resolution, demandKey, "Satisfied by generated demand illustration", $"Applied generated {type} asset illustration from demand key {demandKey}."); asset.ImagePath = resolution.FinalImageUrl ?? asset.ImagePath; return; } var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest( projectId, demandKey, "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown")); var queued = await QueueDemandSpecAsync(AssetDemandSpecification(code, demandKey, type, colour)); asset.ImageResolution = WithDemandDiagnostics( asset.ImageResolution, demandKey, queued ? $"Queued missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})" : $"Demand recorded for missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})", $"No generated compatible {type} asset illustration exists; neutral typed fallback retained while reusable demand is generated.", code, queued ? "DemandQueued" : "DemandRecorded"); } private bool TryApplyGeneratedSupportingImage( string entityId, string code, string fallbackImageUrl, IReadOnlyDictionary> lookup, out StoryIntelligenceExperienceImageResolution resolution) { if (lookup.TryGetValue(code, out var candidates)) { foreach (var candidate in candidates) { var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath); if (publicUrl is null) { continue; } resolution = new StoryIntelligenceExperienceImageResolution { EntityId = entityId, StableCode = code, IllustrationLibraryItemId = candidate.IllustrationLibraryItemID, Status = candidate.Status, FinalImageUrl = publicUrl, FallbackImageUrl = fallbackImageUrl, FallbackUsed = false }; return true; } } resolution = new StoryIntelligenceExperienceImageResolution(); return false; } private static StoryIntelligenceExperienceImageResolution WithDemandDiagnostics( StoryIntelligenceExperienceImageResolution source, string demandKey, string demandStatus, string reasonChosen, string? stableCode = null, string? status = null) => new() { EntityId = source.EntityId, StableCode = stableCode ?? source.StableCode, IllustrationLibraryItemId = source.IllustrationLibraryItemId, Status = status ?? source.Status, FinalImageUrl = source.FinalImageUrl, FallbackImageUrl = source.FallbackImageUrl, FallbackUsed = source.FallbackUsed, AssignmentConfidence = source.AssignmentConfidence, MatchingScore = source.MatchingScore, RejectedCandidates = source.RejectedCandidates, ReasonChosen = reasonChosen, ReasonReassigned = source.ReasonReassigned, PreviousIllustration = source.PreviousIllustration, PreviousEvidence = source.PreviousEvidence, CurrentEvidence = source.CurrentEvidence, DemandKey = demandKey, EvidenceWarnings = source.EvidenceWarnings, DemandStatus = demandStatus, ResolvedPresentation = source.ResolvedPresentation, ResolvedAgeBand = source.ResolvedAgeBand, RelationshipDerivedConfidence = source.RelationshipDerivedConfidence, NameDerivedConfidence = source.NameDerivedConfidence, PronounDerivedConfidence = source.PronounDerivedConfidence, IllustrationReuseCount = source.IllustrationReuseCount, IllustrationAllocationStatus = source.IllustrationAllocationStatus }; private async Task QueueDemandSpecAsync(IllustrationGenerationSpecification specification) { var prompt = promptBuilder.Build(specification); var item = await library.UpsertPlannedAsync(specification, prompt); if (item.Status is IllustrationLibraryStatuses.Queued or IllustrationLibraryStatuses.Generating or IllustrationLibraryStatuses.Generated or IllustrationLibraryStatuses.Approved) { return false; } return await library.QueueAsync(item.IllustrationLibraryItemID, null) > 0; } private MatchScore? ChooseAssignment( CharacterObservation observation, StoryIntelligenceCharacterIllustrationAssignment? existing, MatchScore? existingScore, MatchScore? best) { if (existingScore is not null && existingScore.Candidate.PublicUrl is not null) { if (existingScore.Rejections.Count > 0) { return best; } if (best is null) { return existingScore.Score >= SuitableScore ? existingScore : null; } var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence); if (materiallyWrong) { return best; } if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin) { return existingScore.Score >= SuitableScore ? existingScore : best; } } if (best is not null) { return best; } return existing is null ? null : existingScore; } private static bool MateriallyWrong(CharacterIllustrationMetadata candidate, CharacterEvidence evidence) => KnownMismatch(evidence.AgeBand, candidate.AgeBand) || KnownMismatch(evidence.Presentation, candidate.Presentation) || KnownMismatch(evidence.HairColour, candidate.HairColour) || KnownMismatch(evidence.SkinTone, candidate.SkinTone); private static bool KnownMismatch(string value, string candidate) => !IsUnknown(value) && !IsUnknown(candidate) && !string.Equals(value, candidate, StringComparison.OrdinalIgnoreCase); private async Task RecordDemandAsync(int projectId, CharacterObservation observation, IReadOnlyList allScores) { var evidence = observation.Evidence; var request = new StoryIntelligenceIllustrationDemandRequest( projectId, DemandKey(evidence), evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone, evidence.HairLength, evidence.Glasses, evidence.FacialHair); var demand = await assignments.UpsertDemandAsync(request); var status = $"Demand recorded ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})"; if (demand.RequestCount >= DemandGenerationThreshold) { var specification = DemandSpecification(demand); var prompt = promptBuilder.Build(specification); var item = await library.UpsertPlannedAsync(specification, prompt); var queued = item.Status == IllustrationLibraryStatuses.Planned ? await library.QueueAsync(item.IllustrationLibraryItemID, null) : 0; status = queued > 0 ? $"Demand threshold reached; queued {item.StableCode}" : $"Demand threshold reached; {item.StableCode} already active"; } logger.LogInformation( "Recorded Story Intelligence illustration demand {DemandKey} for project {ProjectId}. Request count {RequestCount}. Best score {BestScore}. Status {DemandStatus}.", demand.DemandKey, projectId, demand.RequestCount, allScores.FirstOrDefault()?.Score ?? 0, status); return status; } private static string DemandKey(CharacterEvidence evidence) => string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant(); private static string EvidenceTraceJson(CharacterObservation observation, StoryIntelligenceCharacterIllustrationAssignment? existing) => JsonSerializer.Serialize(new { character = observation.Name, identityKey = observation.CharacterKey, canonicalIdentity = observation.CharacterKey, rawExtractedEvidence = CompactEvidence(observation.IdentityEvidence), sceneEvidence = CompactEvidence(observation.TextEvidence), relationshipEvidence = CompactEvidence(observation.RelationshipEvidence), mergedEvidence = CompactEvidence(observation.TextEvidence.Concat(observation.RelationshipEvidence)), previousAssignment = existing is null ? null : new { existing.StableCode, existing.IllustrationLibraryItemID, existing.MatchConfidence, existing.MatchingScore, evidenceChars = existing.EvidenceJson?.Length ?? 0, evidenceHash = string.IsNullOrWhiteSpace(existing.EvidenceJson) ? null : StableHash(existing.EvidenceJson) }, finalMatcherEvidence = observation.Evidence, evidenceSource = "Story Intelligence visualisation character state", canonicalMergeReason = "Character state ID from canonical visualisation identity" }, JsonOptions); private static IReadOnlyList CompactEvidence(IEnumerable evidence) => evidence .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => Trim(value, 160)) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(16) .ToList(); private static string Trim(string? value, int maxLength) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } var clean = Regex.Replace(value.Trim(), @"\s+", " "); return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd() + "..."; } private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand) { var age = NormaliseUnknown(demand.AgeBand, "YoungAdult"); var presentation = NormaliseUnknown(demand.Presentation, "Androgynous"); var hairColour = NormaliseUnknown(demand.HairColour, "Brown"); var hairLength = NormaliseUnknown(demand.HairLength, "Medium"); var skinTone = NormaliseUnknown(demand.SkinTone, BalancedSkinToneFallback(demand.DemandKey)); var features = new List { "DemandGenerated" }; if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase)) { features.Add("Glasses"); } if (!IsUnknown(demand.FacialHair)) { features.Add(demand.FacialHair!); } return new IllustrationGenerationSpecification { Category = IllustrationLibraryCategories.Character, Code = $"char-demand-{StableHash(demand.DemandKey)}", Title = $"{age} {presentation.ToLowerInvariant()} character", Description = "Demand-created reusable character illustration for Story Intelligence matching.", VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {SkinToneInstruction(skinTone)}.", Composition = "clear head-and-shoulders cinematic portrait with readable face, subtle atmospheric story background, clean separation, suitable for circular cropping", Mood = "neutral, story-ready, emotionally readable, subdued and cinematic", ApparentAgeBand = age, Presentation = presentation, HairColour = hairColour, HairLength = hairLength, SkinTone = skinTone, ClothingEra = "Contemporary", ClothingStyle = "Understated reusable story wardrobe, not studio fashion styling", Features = features, Palette = ["dark navy atmosphere", "warm skin tones", "restrained amber accent"], MetadataTags = ["character", "demand-generated", "story-intelligence"], Metadata = new(StringComparer.OrdinalIgnoreCase) { ["libraryPurpose"] = "Story Intelligence demand-generated identifier", ["privacy"] = "generic-no-manuscript-data", ["style"] = "editorial-stylised-realism", ["styleIntent"] = "match-approved-starter-library", ["generationSource"] = "Demand generation", ["demandKey"] = demand.DemandKey, ["requestCount"] = demand.RequestCount.ToString("N0") } }; } private static IllustrationGenerationSpecification LocationDemandSpecification(string code, string demandKey, string type) => new() { Category = IllustrationLibraryCategories.Location, Code = code, Title = $"{ReadableToken(type)} location", Description = "Demand-created reusable location illustration for Story Intelligence matching.", VisualSubject = $"A clear reusable {ReadableToken(type).ToLowerInvariant()} environment suitable for manuscript visualisation.", Composition = "single readable environment, no prominent people, strong central focal point, suitable for circular and wide crops", Mood = "story-ready, atmospheric but clear, visually specific rather than generic", LocationType = type, Era = "Contemporary or late twentieth century", Setting = type is "Road" ? "Exterior" : type is "CarPark" or "HouseExterior" ? "Exterior" : "Interior", Palette = ["clear blue", "warm practical light", "balanced neutral accents"], MetadataTags = ["location", "demand-generated", "story-intelligence"], Metadata = new(StringComparer.OrdinalIgnoreCase) { ["libraryPurpose"] = "Story Intelligence demand-generated location", ["privacy"] = "generic-no-manuscript-data", ["style"] = "editorial-stylised-realism", ["generationSource"] = "Demand generation", ["demandKey"] = demandKey, ["locationType"] = type } }; private static IllustrationGenerationSpecification AssetDemandSpecification(string code, string demandKey, string type, string colour) => new() { Category = IllustrationLibraryCategories.Asset, Code = code, Title = $"{ReadableToken(colour)} {ReadableToken(type)}", Description = "Demand-created reusable asset illustration for Story Intelligence matching.", VisualSubject = colour == "Unknown" ? $"A clear reusable {ReadableToken(type).ToLowerInvariant()} object." : $"A clear reusable {colour.ToLowerInvariant()} {ReadableToken(type).ToLowerInvariant()} object.", Composition = "single dominant centred object, crisp silhouette, full object visible where practical, no text or logos", Mood = "clean, readable, evidence-like, story-ready", ObjectType = type, Colour = colour, Era = "Contemporary or late twentieth century", Palette = colour == "Unknown" ? ["clear blue", "warm light", "balanced neutral"] : [colour.ToLowerInvariant(), "clear blue", "warm light"], MetadataTags = ["asset", "demand-generated", "story-intelligence"], Metadata = new(StringComparer.OrdinalIgnoreCase) { ["libraryPurpose"] = "Story Intelligence demand-generated asset", ["privacy"] = "generic-no-manuscript-data", ["style"] = "editorial-stylised-realism", ["generationSource"] = "Demand generation", ["demandKey"] = demandKey, ["objectType"] = type, ["colour"] = colour } }; private static string ReadableToken(string value) => Regex.Replace(value, "([a-z])([A-Z])", "$1 $2"); private static string BalancedSkinToneFallback(string demandKey) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(demandKey)); return (hash[0] % 3) switch { 0 => "Light", 1 => "Medium", _ => "Dark" }; } private static string SkinToneInstruction(string skinTone) => skinTone switch { StoryIntelligenceIllustrationCompatibility.SkinTones.Light => "must have light/fair skin; do not depict dark or black skin unless explicit manuscript evidence says so", StoryIntelligenceIllustrationCompatibility.SkinTones.Medium => "must have medium skin tone; do not drift to dark or very pale skin", StoryIntelligenceIllustrationCompatibility.SkinTones.Dark => "must have dark skin tone", _ => "not specified" }; private static Dictionary BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype) { var observations = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var scene in prototype.Scenes) { foreach (var character in scene.Characters) { if (string.IsNullOrWhiteSpace(character.Id) || string.IsNullOrWhiteSpace(character.Name)) { continue; } if (!observations.TryGetValue(character.Id, out var observation)) { observation = new CharacterObservation(character.Id, character.Name); observations[character.Id] = observation; } observation.Significance = Math.Max(observation.Significance, character.Weight); observation.TextEvidence.Add(character.Name); observation.TextEvidence.Add(character.Role); observation.TextEvidence.Add(character.Relevance); observation.IdentityEvidence.Add(character.Name); observation.IdentityEvidence.Add(character.Role); observation.IdentityEvidence.Add(character.Relevance); foreach (var relationship in scene.Relationships.Where(item => string.Equals(item.SourceId, character.Id, StringComparison.OrdinalIgnoreCase) || string.Equals(item.TargetId, character.Id, StringComparison.OrdinalIgnoreCase))) { observation.TextEvidence.Add(relationship.Label); observation.TextEvidence.Add(relationship.State); observation.RelationshipEvidence.Add(relationship.Label); observation.RelationshipEvidence.Add(relationship.State); } } } foreach (var observation in observations.Values) { observation.Evidence = CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence)); } return observations; } private static CharacterEvidence InferEvidence(string characterName, IEnumerable identityValues, IEnumerable relationshipValues) { return CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(characterName, identityValues, relationshipValues)); } private static bool ContainsAny(string text, params string[] needles) => needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase)); private static EvidenceSignal RelationshipPresentation(string text) { if (ContainsAny(text, "mother", " mum", "aunt", "sister", "daughter", "wife", "grandmother", " niece")) { return new("Feminine", 0.92m); } if (ContainsAny(text, "father", " dad", "uncle", "brother", "son", "husband", "grandfather", " nephew")) { return new("Masculine", 0.92m); } return EvidenceSignal.Unknown; } private static EvidenceSignal PronounPresentation(string text) { if (ContainsAny(text, " she ", " her ", " hers ", " herself ")) { return new("Feminine", 0.86m); } if (ContainsAny(text, " he ", " him ", " his ", " himself ")) { return new("Masculine", 0.86m); } return EvidenceSignal.Unknown; } private static EvidenceSignal NamePresentation(string text) { if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " annie ", " helen ", " elen ")) { return new("Feminine", 0.82m); } if (ContainsAny(text, " graham ", " george ", " john ", " david ", " simon ", " kevin ", " colin ")) { return new("Masculine", 0.82m); } return EvidenceSignal.Unknown; } private static string FirstKnown(params string[] values) => values.FirstOrDefault(value => !IsUnknown(value) && !string.Equals(value, "Androgynous", StringComparison.OrdinalIgnoreCase)) ?? values.LastOrDefault() ?? "Unknown"; private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned) { var score = 0; var rejected = new List(StoryIntelligenceIllustrationCompatibility.CharacterHardRejections( observation.Evidence.Profile, candidate.Metadata.Profile, isNamedCharacter: !IsUnresolvedName(observation.Name), alreadyAssignedToAnotherSignificantCharacter: alreadyAssigned && observation.IsSignificant)); if (observation.Evidence.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown && candidate.Metadata.AgeBand is StoryIntelligenceIllustrationCompatibility.AgeBands.Child or StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen or StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen) { rejected.Add("unknown age should use an adult-compatible illustration until explicit child or teen evidence appears"); } AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band"); AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, observation.Evidence.HasStrongPresentationEvidence ? 42 : 25, "presentation"); AddScore(observation.Evidence.HairColour, candidate.Metadata.HairColour, 20, "hair colour"); AddScore(observation.Evidence.SkinTone, candidate.Metadata.SkinTone, 20, "skin tone"); AddScore(observation.Evidence.HairLength, candidate.Metadata.HairLength, 8, "hair length"); AddScore(observation.Evidence.Glasses, candidate.Metadata.Glasses, 7, "glasses"); AddScore(observation.Evidence.FacialHair, candidate.Metadata.FacialHair, 7, "facial hair"); if (candidate.Item.Status == IllustrationLibraryStatuses.Approved) { score += 3; } return new(candidate, Math.Clamp(score, 0, 100), alreadyAssigned, rejected); void AddScore(string evidence, string value, int weight, string label) { if (IsUnknown(evidence)) { score += weight / 2; return; } if (string.Equals(evidence, value, StringComparison.OrdinalIgnoreCase)) { score += weight; return; } if (IsUnknown(value)) { score += weight / 3; rejected.Add($"{label} unknown in illustration metadata"); return; } rejected.Add($"{label} mismatch: wanted {evidence}, candidate {value}"); } } private static decimal Confidence(int score) => Math.Round(Math.Clamp(score, 0, 100) / 100m, 2); private static string ReassignmentReason(CharacterObservation observation, MatchScore existing, MatchScore chosen) => 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, CharacterObservation observation, StoryIntelligenceCharacterIllustrationAssignment? existing, MatchScore chosen, StoryIntelligenceCharacterIllustrationAssignment saved, string reason, string? reasonReassigned, string evidenceJson, string candidateDiagnostics) { foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase))) { var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl) ? character.ImagePath : character.ImageResolution.FallbackImageUrl; character.LibraryCode = chosen.Candidate.Item.StableCode; character.ImagePath = chosen.Candidate.PublicUrl!; character.ImageResolution = new StoryIntelligenceExperienceImageResolution { EntityId = character.Id, StableCode = chosen.Candidate.Item.StableCode, IllustrationLibraryItemId = chosen.Candidate.Item.IllustrationLibraryItemID, Status = chosen.Candidate.Item.Status, FinalImageUrl = chosen.Candidate.PublicUrl, FallbackImageUrl = fallbackImageUrl, FallbackUsed = false, AssignmentConfidence = saved.MatchConfidence, MatchingScore = saved.MatchingScore, RejectedCandidates = candidateDiagnostics, ReasonChosen = reason, ReasonReassigned = reasonReassigned, PreviousIllustration = existing?.StableCode, PreviousEvidence = existing?.EvidenceJson, CurrentEvidence = evidenceJson, DemandKey = DemandKey(observation.Evidence), EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings), DemandStatus = "Satisfied", ResolvedPresentation = observation.Evidence.Presentation, ResolvedAgeBand = observation.Evidence.AgeBand, RelationshipDerivedConfidence = observation.Evidence.RelationshipConfidence, NameDerivedConfidence = observation.Evidence.NameConfidence, PronounDerivedConfidence = observation.Evidence.PronounConfidence, IllustrationReuseCount = 1, IllustrationAllocationStatus = "Unique allocation" }; } } private static void ApplyDemandDiagnostics(StoryIntelligenceExperiencePrototypeViewModel prototype, CharacterObservation observation, IReadOnlyList allScores, string demandStatus) { var diagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions); foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase))) { var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl) ? character.ImagePath : character.ImageResolution.FallbackImageUrl; character.LibraryCode = string.Empty; if (!string.IsNullOrWhiteSpace(fallbackImageUrl)) { character.ImagePath = fallbackImageUrl; } character.ImageResolution = new StoryIntelligenceExperienceImageResolution { EntityId = character.Id, StableCode = string.Empty, IllustrationLibraryItemId = null, Status = "DemandRecorded", FinalImageUrl = null, FallbackImageUrl = fallbackImageUrl, FallbackUsed = true, MatchingScore = allScores.FirstOrDefault()?.Score, RejectedCandidates = diagnostics, ReasonChosen = "No suitable unused illustration matched structured character evidence.", CurrentEvidence = EvidenceTraceJson(observation, null), DemandKey = DemandKey(observation.Evidence), EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings), DemandStatus = demandStatus, ResolvedPresentation = observation.Evidence.Presentation, ResolvedAgeBand = observation.Evidence.AgeBand, RelationshipDerivedConfidence = observation.Evidence.RelationshipConfidence, NameDerivedConfidence = observation.Evidence.NameConfidence, PronounDerivedConfidence = observation.Evidence.PronounConfidence, IllustrationAllocationStatus = "Awaiting generated unique illustration" }; } } private string? ResolveExistingPublicUploadUrl(string? storedPath) { var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library"); if (physicalPath is null || !File.Exists(physicalPath)) { return null; } var normalised = storedPath!.Replace('\\', '/').Trim(); if (!normalised.StartsWith('/')) { normalised = "/" + normalised.TrimStart('/'); } return EncodeUrlPath(normalised); } private static string EncodeUrlPath(string path) => string.Join( '/', path.Split('/', StringSplitOptions.None) .Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment))); private static bool IsUnknown(string? value) => string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase); private static string FirstConfigured(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; 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 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 = EvidenceTraceJson(observation, null), DemandStatus = "Not required for group entity", ResolvedPresentation = "Unknown", ResolvedAgeBand = "Unknown", IllustrationAllocationStatus = "Group entity" }; } } private static string NormaliseUnknown(string? value, string fallback) => IsUnknown(value) ? fallback : value!; private static string StableHash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12].ToLowerInvariant(); private sealed class CharacterObservation(string characterKey, string name) { public string CharacterKey { get; } = characterKey; public string Name { get; } = name; public int Significance { get; set; } public bool IsSignificant => Significance >= 50; public List TextEvidence { get; } = []; public List IdentityEvidence { get; } = []; public List RelationshipEvidence { get; } = []; public CharacterEvidence Evidence { get; set; } = CharacterEvidence.Unknown; } private sealed record EvidenceSignal(string Value, decimal Confidence) { public static EvidenceSignal Unknown { get; } = new("Unknown", 0); } private sealed record CharacterEvidence( string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair, decimal RelationshipConfidence, decimal NameConfidence, decimal PronounConfidence, IReadOnlyList Warnings, CharacterEvidenceProfile Profile) { public bool HasStrongPresentationEvidence => RelationshipConfidence >= 0.8m || NameConfidence >= 0.75m || PronounConfidence >= 0.8m; 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.Warnings, profile); } private sealed record CharacterCandidate(IllustrationLibraryItem Item, CharacterIllustrationMetadata Metadata, string? PublicUrl) { public static CharacterCandidate? From(IllustrationLibraryItem item) { var metadata = CharacterIllustrationMetadata.From(item); return metadata is null ? null : new(item, metadata, null); } } private sealed record CharacterIllustrationMetadata(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair, IllustrationCharacterMetadata Profile) { public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item) { var profile = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item); return new( profile.AgeBand, profile.Presentation, profile.HairColour, profile.SkinTone, profile.HairLength, profile.Glasses, profile.FacialHair, profile); } } private sealed record MatchScore(CharacterCandidate Candidate, int Score, bool IsAlreadyAssignedToAnotherSignificantCharacter, IReadOnlyList Rejections); private sealed record MatchDiagnostics(int IllustrationLibraryItemID, string StableCode, int Score, IReadOnlyList Rejections) { public static MatchDiagnostics From(MatchScore score) => new(score.Candidate.Item.IllustrationLibraryItemID, score.Candidate.Item.StableCode, score.Score, score.Rejections); } }