1032 lines
48 KiB
C#
1032 lines
48 KiB
C#
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
using PlotLine.ViewModels;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IStoryIntelligenceIllustrationMatchingService
|
|
{
|
|
Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
|
|
Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
|
|
}
|
|
|
|
public sealed class StoryIntelligenceIllustrationMatchingService(
|
|
IIllustrationLibraryRepository library,
|
|
IStoryIntelligenceIllustrationMatchingRepository assignments,
|
|
IIllustrationPromptBuilder promptBuilder,
|
|
IUploadStorageService uploadStorage,
|
|
ILogger<StoryIntelligenceIllustrationMatchingService> logger) : IStoryIntelligenceIllustrationMatchingService
|
|
{
|
|
private const int SuitableScore = 48;
|
|
private const int ReassignmentMargin = 24;
|
|
private const int DemandGenerationThreshold = 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<CharacterCandidate>()
|
|
.Select(candidate => candidate with { PublicUrl = ResolveExistingPublicUploadUrl(candidate.Item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.Item.FilePath) })
|
|
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.PublicUrl))
|
|
.ToList();
|
|
|
|
if (catalogue.Count == 0)
|
|
{
|
|
logger.LogWarning("No generated character illustrations are available for Story Intelligence matching.");
|
|
return;
|
|
}
|
|
|
|
var existingAssignments = (await assignments.ListAssignmentsAsync(projectId))
|
|
.ToDictionary(item => item.CharacterKey, StringComparer.OrdinalIgnoreCase);
|
|
var assignedItemIds = existingAssignments.Values
|
|
.Where(item => item.IllustrationLibraryItemID.HasValue)
|
|
.Select(item => item.IllustrationLibraryItemID!.Value)
|
|
.ToHashSet();
|
|
var duplicateExistingItemIds = existingAssignments.Values
|
|
.Where(item => item.IllustrationLibraryItemID.HasValue)
|
|
.GroupBy(item => item.IllustrationLibraryItemID!.Value)
|
|
.Where(group => group.Select(item => item.CharacterKey).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
|
|
.Select(group => group.Key)
|
|
.ToHashSet();
|
|
var retainedExistingItemIds = new HashSet<int>();
|
|
var observations = BuildCharacterObservations(prototype);
|
|
|
|
foreach (var observation in observations.Values.OrderByDescending(item => item.Significance).ThenBy(item => item.Name))
|
|
{
|
|
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 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<string, List<IllustrationLibraryItem>> 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<string, List<IllustrationLibraryItem>> 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<string, List<IllustrationLibraryItem>> 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<bool> 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<string> RecordDemandAsync(int projectId, CharacterObservation observation, IReadOnlyList<MatchScore> allScores)
|
|
{
|
|
var evidence = observation.Evidence;
|
|
var request = new StoryIntelligenceIllustrationDemandRequest(
|
|
projectId,
|
|
DemandKey(evidence),
|
|
evidence.AgeBand,
|
|
evidence.Presentation,
|
|
evidence.HairColour,
|
|
evidence.SkinTone,
|
|
evidence.HairLength,
|
|
evidence.Glasses,
|
|
evidence.FacialHair);
|
|
var demand = await assignments.UpsertDemandAsync(request);
|
|
var status = $"Demand recorded ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})";
|
|
if (demand.RequestCount >= DemandGenerationThreshold)
|
|
{
|
|
var specification = DemandSpecification(demand);
|
|
var prompt = promptBuilder.Build(specification);
|
|
var item = await library.UpsertPlannedAsync(specification, prompt);
|
|
var queued = item.Status == IllustrationLibraryStatuses.Planned
|
|
? await library.QueueAsync(item.IllustrationLibraryItemID, null)
|
|
: 0;
|
|
status = queued > 0
|
|
? $"Demand threshold reached; queued {item.StableCode}"
|
|
: $"Demand threshold reached; {item.StableCode} already active";
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Recorded Story Intelligence illustration demand {DemandKey} for project {ProjectId}. Request count {RequestCount}. Best score {BestScore}. Status {DemandStatus}.",
|
|
demand.DemandKey,
|
|
projectId,
|
|
demand.RequestCount,
|
|
allScores.FirstOrDefault()?.Score ?? 0,
|
|
status);
|
|
return status;
|
|
}
|
|
|
|
private static string DemandKey(CharacterEvidence evidence)
|
|
=> string.Join('|', "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<string> CompactEvidence(IEnumerable<string> 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<string> { "DemandGenerated" };
|
|
if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
features.Add("Glasses");
|
|
}
|
|
|
|
if (!IsUnknown(demand.FacialHair))
|
|
{
|
|
features.Add(demand.FacialHair!);
|
|
}
|
|
|
|
return new IllustrationGenerationSpecification
|
|
{
|
|
Category = IllustrationLibraryCategories.Character,
|
|
Code = $"char-demand-{StableHash(demand.DemandKey)}",
|
|
Title = $"{age} {presentation.ToLowerInvariant()} character",
|
|
Description = "Demand-created reusable character illustration for Story Intelligence matching.",
|
|
VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair and {skinTone.ToLowerInvariant()} skin tone range.",
|
|
Composition = "clear head-and-shoulders 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 Dictionary<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype)
|
|
{
|
|
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var scene in prototype.Scenes)
|
|
{
|
|
foreach (var character in scene.Characters)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(character.Id) || string.IsNullOrWhiteSpace(character.Name))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!observations.TryGetValue(character.Id, out var observation))
|
|
{
|
|
observation = new CharacterObservation(character.Id, character.Name);
|
|
observations[character.Id] = observation;
|
|
}
|
|
|
|
observation.Significance = Math.Max(observation.Significance, character.Weight);
|
|
observation.TextEvidence.Add(character.Name);
|
|
observation.TextEvidence.Add(character.Role);
|
|
observation.TextEvidence.Add(character.Relevance);
|
|
observation.IdentityEvidence.Add(character.Name);
|
|
observation.IdentityEvidence.Add(character.Role);
|
|
observation.IdentityEvidence.Add(character.Relevance);
|
|
foreach (var relationship in scene.Relationships.Where(item => string.Equals(item.SourceId, character.Id, StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(item.TargetId, character.Id, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
observation.TextEvidence.Add(relationship.Label);
|
|
observation.TextEvidence.Add(relationship.State);
|
|
observation.RelationshipEvidence.Add(relationship.Label);
|
|
observation.RelationshipEvidence.Add(relationship.State);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var observation in observations.Values)
|
|
{
|
|
observation.Evidence = CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence));
|
|
}
|
|
|
|
return observations;
|
|
}
|
|
|
|
private static CharacterEvidence InferEvidence(string characterName, IEnumerable<string> identityValues, IEnumerable<string> 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<string>(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<MatchScore> allScores, string demandStatus)
|
|
{
|
|
var diagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
|
|
foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl)
|
|
? character.ImagePath
|
|
: character.ImageResolution.FallbackImageUrl;
|
|
character.LibraryCode = string.Empty;
|
|
if (!string.IsNullOrWhiteSpace(fallbackImageUrl))
|
|
{
|
|
character.ImagePath = fallbackImageUrl;
|
|
}
|
|
|
|
character.ImageResolution = new StoryIntelligenceExperienceImageResolution
|
|
{
|
|
EntityId = character.Id,
|
|
StableCode = string.Empty,
|
|
IllustrationLibraryItemId = null,
|
|
Status = "DemandRecorded",
|
|
FinalImageUrl = null,
|
|
FallbackImageUrl = fallbackImageUrl,
|
|
FallbackUsed = true,
|
|
MatchingScore = allScores.FirstOrDefault()?.Score,
|
|
RejectedCandidates = diagnostics,
|
|
ReasonChosen = "No suitable unused illustration matched structured character evidence.",
|
|
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 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<string> TextEvidence { get; } = [];
|
|
public List<string> IdentityEvidence { get; } = [];
|
|
public List<string> RelationshipEvidence { get; } = [];
|
|
public CharacterEvidence Evidence { get; set; } = CharacterEvidence.Unknown;
|
|
}
|
|
|
|
private sealed record EvidenceSignal(string Value, decimal Confidence)
|
|
{
|
|
public static EvidenceSignal Unknown { get; } = new("Unknown", 0);
|
|
}
|
|
|
|
private sealed record CharacterEvidence(
|
|
string AgeBand,
|
|
string Presentation,
|
|
string HairColour,
|
|
string SkinTone,
|
|
string HairLength,
|
|
string Glasses,
|
|
string FacialHair,
|
|
decimal RelationshipConfidence,
|
|
decimal NameConfidence,
|
|
decimal PronounConfidence,
|
|
IReadOnlyList<string> 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<string> Rejections);
|
|
|
|
private sealed record MatchDiagnostics(int IllustrationLibraryItemID, string StableCode, int Score, IReadOnlyList<string> Rejections)
|
|
{
|
|
public static MatchDiagnostics From(MatchScore score)
|
|
=> new(score.Candidate.Item.IllustrationLibraryItemID, score.Candidate.Item.StableCode, score.Score, score.Rejections);
|
|
}
|
|
}
|