Phase 21I - Intelligent Illustration Matching Engine
This commit is contained in:
parent
fd85473258
commit
b78a9bea9b
@ -0,0 +1,93 @@
|
|||||||
|
using Dapper;
|
||||||
|
using PlotLine.Models;
|
||||||
|
|
||||||
|
namespace PlotLine.Data;
|
||||||
|
|
||||||
|
public interface IStoryIntelligenceIllustrationMatchingRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<StoryIntelligenceCharacterIllustrationAssignment>> ListAssignmentsAsync(int projectId);
|
||||||
|
Task<StoryIntelligenceCharacterIllustrationAssignment> UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request);
|
||||||
|
Task<StoryIntelligenceIllustrationDemand> UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceIllustrationMatchingRepository
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<StoryIntelligenceCharacterIllustrationAssignment>> ListAssignmentsAsync(int projectId)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
var rows = await connection.QueryAsync<StoryIntelligenceCharacterIllustrationAssignment>(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
|
||||||
|
WHERE ProjectID = @ProjectID
|
||||||
|
AND IsActive = 1;
|
||||||
|
""",
|
||||||
|
new { ProjectID = projectId });
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoryIntelligenceCharacterIllustrationAssignment> UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleAsync<StoryIntelligenceCharacterIllustrationAssignment>(
|
||||||
|
"""
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
MERGE dbo.StoryIntelligenceCharacterIllustrationAssignments WITH (HOLDLOCK) AS target
|
||||||
|
USING (SELECT @ProjectID AS ProjectID, @CharacterKey AS CharacterKey) AS source
|
||||||
|
ON target.ProjectID = source.ProjectID
|
||||||
|
AND target.CharacterKey = source.CharacterKey
|
||||||
|
AND target.IsActive = 1
|
||||||
|
WHEN MATCHED THEN
|
||||||
|
UPDATE SET
|
||||||
|
CharacterName = @CharacterName,
|
||||||
|
IllustrationLibraryItemID = @IllustrationLibraryItemID,
|
||||||
|
StableCode = @StableCode,
|
||||||
|
MatchConfidence = @MatchConfidence,
|
||||||
|
MatchingScore = @MatchingScore,
|
||||||
|
EvidenceJson = @EvidenceJson,
|
||||||
|
CandidateDiagnosticsJson = @CandidateDiagnosticsJson,
|
||||||
|
ReasonChosen = @ReasonChosen,
|
||||||
|
ReasonReassigned = @ReasonReassigned,
|
||||||
|
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);
|
||||||
|
|
||||||
|
SELECT *
|
||||||
|
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
|
||||||
|
WHERE ProjectID = @ProjectID
|
||||||
|
AND CharacterKey = @CharacterKey
|
||||||
|
AND IsActive = 1;
|
||||||
|
""",
|
||||||
|
request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoryIntelligenceIllustrationDemand> UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleAsync<StoryIntelligenceIllustrationDemand>(
|
||||||
|
"""
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
MERGE dbo.StoryIntelligenceIllustrationDemands WITH (HOLDLOCK) AS target
|
||||||
|
USING (SELECT @ProjectID AS ProjectID, @DemandKey AS DemandKey) AS source
|
||||||
|
ON ISNULL(target.ProjectID, -1) = ISNULL(source.ProjectID, -1)
|
||||||
|
AND target.DemandKey = source.DemandKey
|
||||||
|
WHEN MATCHED THEN
|
||||||
|
UPDATE SET
|
||||||
|
RequestCount = RequestCount + 1,
|
||||||
|
LastRequestedUtc = SYSUTCDATETIME(),
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHEN NOT MATCHED THEN
|
||||||
|
INSERT (ProjectID, DemandKey, AgeBand, Presentation, HairColour, SkinTone, HairLength, Glasses, FacialHair)
|
||||||
|
VALUES (@ProjectID, @DemandKey, @AgeBand, @Presentation, @HairColour, @SkinTone, @HairLength, @Glasses, @FacialHair);
|
||||||
|
|
||||||
|
SELECT *
|
||||||
|
FROM dbo.StoryIntelligenceIllustrationDemands
|
||||||
|
WHERE ISNULL(ProjectID, -1) = ISNULL(@ProjectID, -1)
|
||||||
|
AND DemandKey = @DemandKey;
|
||||||
|
""",
|
||||||
|
request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
namespace PlotLine.Models;
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceCharacterIllustrationAssignment
|
||||||
|
{
|
||||||
|
public int StoryIntelligenceCharacterIllustrationAssignmentID { get; init; }
|
||||||
|
public int ProjectID { get; init; }
|
||||||
|
public string CharacterKey { get; init; } = string.Empty;
|
||||||
|
public string CharacterName { get; init; } = string.Empty;
|
||||||
|
public int? IllustrationLibraryItemID { get; init; }
|
||||||
|
public string? StableCode { get; init; }
|
||||||
|
public decimal MatchConfidence { get; init; }
|
||||||
|
public int MatchingScore { get; init; }
|
||||||
|
public string? EvidenceJson { get; init; }
|
||||||
|
public string? CandidateDiagnosticsJson { get; init; }
|
||||||
|
public string? ReasonChosen { get; init; }
|
||||||
|
public string? ReasonReassigned { get; init; }
|
||||||
|
public bool IsActive { get; init; }
|
||||||
|
public DateTime CreatedUtc { get; init; }
|
||||||
|
public DateTime UpdatedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StoryIntelligenceCharacterIllustrationAssignmentSave(
|
||||||
|
int ProjectID,
|
||||||
|
string CharacterKey,
|
||||||
|
string CharacterName,
|
||||||
|
int? IllustrationLibraryItemID,
|
||||||
|
string? StableCode,
|
||||||
|
decimal MatchConfidence,
|
||||||
|
int MatchingScore,
|
||||||
|
string? EvidenceJson,
|
||||||
|
string? CandidateDiagnosticsJson,
|
||||||
|
string? ReasonChosen,
|
||||||
|
string? ReasonReassigned);
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceIllustrationDemand
|
||||||
|
{
|
||||||
|
public int StoryIntelligenceIllustrationDemandID { get; init; }
|
||||||
|
public int? ProjectID { get; init; }
|
||||||
|
public string DemandKey { get; init; } = string.Empty;
|
||||||
|
public string AgeBand { get; init; } = string.Empty;
|
||||||
|
public string Presentation { get; init; } = string.Empty;
|
||||||
|
public string HairColour { get; init; } = string.Empty;
|
||||||
|
public string SkinTone { get; init; } = string.Empty;
|
||||||
|
public string? HairLength { get; init; }
|
||||||
|
public string? Glasses { get; init; }
|
||||||
|
public string? FacialHair { get; init; }
|
||||||
|
public int RequestCount { get; init; }
|
||||||
|
public DateTime LastRequestedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StoryIntelligenceIllustrationDemandRequest(
|
||||||
|
int? ProjectID,
|
||||||
|
string DemandKey,
|
||||||
|
string AgeBand,
|
||||||
|
string Presentation,
|
||||||
|
string HairColour,
|
||||||
|
string SkinTone,
|
||||||
|
string? HairLength,
|
||||||
|
string? Glasses,
|
||||||
|
string? FacialHair);
|
||||||
@ -144,6 +144,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
|
||||||
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
|
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
|
||||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||||
@ -223,6 +224,7 @@ public class Program
|
|||||||
builder.Services.AddHttpClient<IIllustrationImageProvider, OpenAIIllustrationImageProvider>();
|
builder.Services.AddHttpClient<IIllustrationImageProvider, OpenAIIllustrationImageProvider>();
|
||||||
builder.Services.AddScoped<IIllustrationGenerationRunner, IllustrationGenerationRunner>();
|
builder.Services.AddScoped<IIllustrationGenerationRunner, IllustrationGenerationRunner>();
|
||||||
builder.Services.AddScoped<IIllustrationLibraryService, IllustrationLibraryService>();
|
builder.Services.AddScoped<IIllustrationLibraryService, IllustrationLibraryService>();
|
||||||
|
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingService, StoryIntelligenceIllustrationMatchingService>();
|
||||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||||
|
|||||||
@ -32,7 +32,21 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
|
|||||||
throw new ArgumentException(string.Join(" ", errors), nameof(specification));
|
throw new ArgumentException(string.Join(" ", errors), nameof(specification));
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadataJson = JsonSerializer.Serialize(specification.Metadata, JsonOptions);
|
var metadata = new Dictionary<string, string>(specification.Metadata, StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (string.Equals(specification.Category, IllustrationLibraryCategories.Character, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
AddMetadata("ageBand", specification.ApparentAgeBand);
|
||||||
|
AddMetadata("presentation", specification.Presentation);
|
||||||
|
AddMetadata("hairColour", specification.HairColour);
|
||||||
|
AddMetadata("skinTone", specification.SkinTone);
|
||||||
|
AddMetadata("hairLength", specification.HairLength);
|
||||||
|
AddMetadata("glasses", specification.Features.Any(feature => feature.Contains("glasses", StringComparison.OrdinalIgnoreCase)) ? "Yes" : "Unknown");
|
||||||
|
AddMetadata("facialHair", specification.Features.FirstOrDefault(feature => feature.Contains("beard", StringComparison.OrdinalIgnoreCase) || feature.Contains("moustache", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
AddMetadata("clothingEra", specification.ClothingEra);
|
||||||
|
AddMetadata("clothingStyle", specification.ClothingStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadataJson = JsonSerializer.Serialize(metadata, JsonOptions);
|
||||||
var specificationJson = JsonSerializer.Serialize(specification, JsonOptions);
|
var specificationJson = JsonSerializer.Serialize(specification, JsonOptions);
|
||||||
var palette = specification.Palette.Count == 0 ? "balanced editorial colour, not monochrome" : string.Join(", ", specification.Palette);
|
var palette = specification.Palette.Count == 0 ? "balanced editorial colour, not monochrome" : string.Join(", ", specification.Palette);
|
||||||
var tags = specification.MetadataTags.Count == 0 ? "generic reusable story identifier" : string.Join(", ", specification.MetadataTags);
|
var tags = specification.MetadataTags.Count == 0 ? "generic reusable story identifier" : string.Join(", ", specification.MetadataTags);
|
||||||
@ -71,6 +85,14 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
return new(prompt, CurrentTemplateVersion, specificationJson, metadataJson);
|
return new(prompt, CurrentTemplateVersion, specificationJson, metadataJson);
|
||||||
|
|
||||||
|
void AddMetadata(string key, string? value)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
metadata[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CategoryRules(string category)
|
private static string CategoryRules(string category)
|
||||||
|
|||||||
@ -0,0 +1,508 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using PlotLine.Data;
|
||||||
|
using PlotLine.Models;
|
||||||
|
using PlotLine.ViewModels;
|
||||||
|
|
||||||
|
namespace PlotLine.Services;
|
||||||
|
|
||||||
|
public interface IStoryIntelligenceIllustrationMatchingService
|
||||||
|
{
|
||||||
|
Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||||
|
IIllustrationLibraryRepository library,
|
||||||
|
IStoryIntelligenceIllustrationMatchingRepository assignments,
|
||||||
|
IUploadStorageService uploadStorage,
|
||||||
|
ILogger<StoryIntelligenceIllustrationMatchingService> logger) : IStoryIntelligenceIllustrationMatchingService
|
||||||
|
{
|
||||||
|
private const int SuitableScore = 48;
|
||||||
|
private const int ReassignmentMargin = 24;
|
||||||
|
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 observations = BuildCharacterObservations(prototype);
|
||||||
|
|
||||||
|
foreach (var observation in observations.Values.OrderByDescending(item => item.Significance).ThenBy(item => item.Name))
|
||||||
|
{
|
||||||
|
var existing = existingAssignments.GetValueOrDefault(observation.CharacterKey);
|
||||||
|
var ownExistingItemId = existing?.IllustrationLibraryItemID;
|
||||||
|
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 => observation.IsSignificant ? !score.IsAlreadyAssignedToAnotherSignificantCharacter : true)
|
||||||
|
.FirstOrDefault(score => score.Score >= SuitableScore);
|
||||||
|
|
||||||
|
var chosen = ChooseAssignment(observation, existing, existingScore, best);
|
||||||
|
if (chosen is null)
|
||||||
|
{
|
||||||
|
await RecordDemandAsync(projectId, observation, allScores);
|
||||||
|
ApplyDemandDiagnostics(prototype, observation, allScores);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reasonReassigned = existingScore is not null && chosen.Candidate.Item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID
|
||||||
|
? ReassignmentReason(observation, existingScore, chosen)
|
||||||
|
: null;
|
||||||
|
var reason = reasonReassigned ?? (existing is null
|
||||||
|
? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence."
|
||||||
|
: "Preserved existing Story Intelligence character illustration assignment.");
|
||||||
|
var candidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
|
||||||
|
var evidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions);
|
||||||
|
|
||||||
|
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, chosen, saved, reason, reasonReassigned, candidateDiagnostics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MatchScore? ChooseAssignment(
|
||||||
|
CharacterObservation observation,
|
||||||
|
StoryIntelligenceCharacterIllustrationAssignment? existing,
|
||||||
|
MatchScore? existingScore,
|
||||||
|
MatchScore? best)
|
||||||
|
{
|
||||||
|
if (existingScore is not null && existingScore.Candidate.PublicUrl is not null)
|
||||||
|
{
|
||||||
|
if (best is null)
|
||||||
|
{
|
||||||
|
return existingScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence);
|
||||||
|
if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin)
|
||||||
|
{
|
||||||
|
return existingScore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Recorded Story Intelligence illustration demand {DemandKey} for project {ProjectId}. Request count {RequestCount}. Best score {BestScore}.",
|
||||||
|
demand.DemandKey,
|
||||||
|
projectId,
|
||||||
|
demand.RequestCount,
|
||||||
|
allScores.FirstOrDefault()?.Score ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DemandKey(CharacterEvidence evidence)
|
||||||
|
=> string.Join('|', evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone, evidence.HairLength, evidence.Glasses, evidence.FacialHair).ToLowerInvariant();
|
||||||
|
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var observation in observations.Values)
|
||||||
|
{
|
||||||
|
observation.Evidence = InferEvidence(observation.TextEvidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
return observations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharacterEvidence InferEvidence(IEnumerable<string> values)
|
||||||
|
{
|
||||||
|
var text = string.Join(' ', values.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant();
|
||||||
|
var age = ContainsAny(text, "grandfather", "grandmother", "elderly", "old man", "old woman", "older woman", "older man") ? "Elderly"
|
||||||
|
: ContainsAny(text, "middle aged", "middle-aged", "mother", "father", "parent", "aunt", "uncle") ? "Middle Aged"
|
||||||
|
: ContainsAny(text, "young teen", "child", "boy", "girl") ? "Child"
|
||||||
|
: ContainsAny(text, "older teen", "teenage", "teenager", "fifteen", "sixteen", "seventeen") ? "Older Teen"
|
||||||
|
: ContainsAny(text, "young adult", "student", "young woman", "young man") ? "Young Adult"
|
||||||
|
: "Unknown";
|
||||||
|
var presentation = ContainsAny(text, "she ", " her ", "herself", "woman", "girl", "mother", "daughter", "sister", "feminine") ? "Feminine"
|
||||||
|
: ContainsAny(text, " he ", " him ", "himself", "man", "boy", "father", "son", "brother", "masculine") ? "Masculine"
|
||||||
|
: "Androgynous";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAny(string text, params string[] needles)
|
||||||
|
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned)
|
||||||
|
{
|
||||||
|
var score = 0;
|
||||||
|
var rejected = new List<string>();
|
||||||
|
AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band");
|
||||||
|
AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alreadyAssigned && observation.IsSignificant)
|
||||||
|
{
|
||||||
|
score -= 12;
|
||||||
|
rejected.Add("already assigned to another significant character");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(candidate, Math.Clamp(score, 0, 100), alreadyAssigned && observation.IsSignificant, 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)
|
||||||
|
=> $"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,
|
||||||
|
MatchScore chosen,
|
||||||
|
StoryIntelligenceCharacterIllustrationAssignment saved,
|
||||||
|
string reason,
|
||||||
|
string? reasonReassigned,
|
||||||
|
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,
|
||||||
|
DemandStatus = "Satisfied"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyDemandDiagnostics(StoryIntelligenceExperiencePrototypeViewModel prototype, CharacterObservation observation, IReadOnlyList<MatchScore> allScores)
|
||||||
|
{
|
||||||
|
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)))
|
||||||
|
{
|
||||||
|
character.ImageResolution = new StoryIntelligenceExperienceImageResolution
|
||||||
|
{
|
||||||
|
EntityId = character.Id,
|
||||||
|
StableCode = character.LibraryCode,
|
||||||
|
IllustrationLibraryItemId = character.ImageResolution.IllustrationLibraryItemId,
|
||||||
|
Status = character.ImageResolution.Status,
|
||||||
|
FinalImageUrl = character.ImageResolution.FinalImageUrl,
|
||||||
|
FallbackImageUrl = character.ImageResolution.FallbackImageUrl,
|
||||||
|
FallbackUsed = character.ImageResolution.FallbackUsed,
|
||||||
|
MatchingScore = allScores.FirstOrDefault()?.Score,
|
||||||
|
RejectedCandidates = diagnostics,
|
||||||
|
ReasonChosen = "No suitable unused illustration matched structured character evidence.",
|
||||||
|
DemandStatus = "Demand recorded"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 CharacterEvidence Evidence { get; set; } = CharacterEvidence.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CharacterEvidence(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair)
|
||||||
|
{
|
||||||
|
public static CharacterEvidence Unknown { get; } = new("Unknown", "Androgynous", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item)
|
||||||
|
{
|
||||||
|
IllustrationGenerationSpecification? specification = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(item.SpecificationJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
specification = JsonSerializer.Deserialize<IllustrationGenerationSpecification>(item.SpecificationJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(
|
||||||
|
NormaliseAge(specification?.ApparentAgeBand, item.StableCode),
|
||||||
|
NormalisePresentation(specification?.Presentation, item.StableCode),
|
||||||
|
NormaliseHairColour(specification?.HairColour, item.StableCode),
|
||||||
|
NormaliseSkinTone(specification?.SkinTone, item.StableCode),
|
||||||
|
NormaliseHairLength(specification?.HairLength, item.StableCode),
|
||||||
|
FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown",
|
||||||
|
FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
IStoryIntelligencePipelineRepository pipelines,
|
IStoryIntelligencePipelineRepository pipelines,
|
||||||
IStoryIntelligenceSourceRepository sources,
|
IStoryIntelligenceSourceRepository sources,
|
||||||
IIllustrationLibraryService illustrationLibrary,
|
IIllustrationLibraryService illustrationLibrary,
|
||||||
|
IStoryIntelligenceIllustrationMatchingService illustrationMatcher,
|
||||||
ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
|
ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
|
||||||
{
|
{
|
||||||
private const string FallbackRoot = "/images/story-intelligence/prototype";
|
private const string FallbackRoot = "/images/story-intelligence/prototype";
|
||||||
@ -137,6 +138,10 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
|
|
||||||
StabiliseCharacterIllustrations(model);
|
StabiliseCharacterIllustrations(model);
|
||||||
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
||||||
|
if (run.ProjectID.HasValue)
|
||||||
|
{
|
||||||
|
await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model);
|
||||||
|
}
|
||||||
logger.LogInformation("Built Story Intelligence visualisation snapshot for run {RunId} in {ElapsedMs}ms.", runId, stopwatch.ElapsedMilliseconds);
|
logger.LogInformation("Built Story Intelligence visualisation snapshot for run {RunId} in {ElapsedMs}ms.", runId, stopwatch.ElapsedMilliseconds);
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
@ -239,6 +244,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
|
|
||||||
StabiliseCharacterIllustrations(model);
|
StabiliseCharacterIllustrations(model);
|
||||||
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
||||||
|
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model);
|
||||||
logger.LogInformation("Built Story Intelligence import session visualisation snapshot for session {ImportSessionId} in {ElapsedMs}ms.", importSessionId, stopwatch.ElapsedMilliseconds);
|
logger.LogInformation("Built Story Intelligence import session visualisation snapshot for session {ImportSessionId} in {ElapsedMs}ms.", importSessionId, stopwatch.ElapsedMilliseconds);
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,79 @@
|
|||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments
|
||||||
|
(
|
||||||
|
StoryIntelligenceCharacterIllustrationAssignmentID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceCharacterIllustrationAssignments PRIMARY KEY,
|
||||||
|
ProjectID int NOT NULL,
|
||||||
|
CharacterKey nvarchar(160) NOT NULL,
|
||||||
|
CharacterName nvarchar(200) NOT NULL,
|
||||||
|
IllustrationLibraryItemID int NULL,
|
||||||
|
StableCode nvarchar(120) NULL,
|
||||||
|
MatchConfidence decimal(5,2) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_MatchConfidence DEFAULT 0,
|
||||||
|
MatchingScore int NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_MatchingScore DEFAULT 0,
|
||||||
|
EvidenceJson nvarchar(max) NULL,
|
||||||
|
CandidateDiagnosticsJson nvarchar(max) NULL,
|
||||||
|
ReasonChosen nvarchar(500) NULL,
|
||||||
|
ReasonReassigned nvarchar(500) NULL,
|
||||||
|
IsActive bit NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_IsActive DEFAULT 1,
|
||||||
|
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_CreatedUtc DEFAULT SYSUTCDATETIME(),
|
||||||
|
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_UpdatedUtc DEFAULT SYSUTCDATETIME()
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_SICharacterIllustrationAssignments_ProjectCharacter' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments'))
|
||||||
|
BEGIN
|
||||||
|
CREATE UNIQUE INDEX UX_SICharacterIllustrationAssignments_ProjectCharacter
|
||||||
|
ON dbo.StoryIntelligenceCharacterIllustrationAssignments(ProjectID, CharacterKey)
|
||||||
|
WHERE IsActive = 1;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_SICharacterIllustrationAssignments_Project')
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments
|
||||||
|
ADD CONSTRAINT FK_SICharacterIllustrationAssignments_Project
|
||||||
|
FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_SICharacterIllustrationAssignments_Illustration')
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments
|
||||||
|
ADD CONSTRAINT FK_SICharacterIllustrationAssignments_Illustration
|
||||||
|
FOREIGN KEY (IllustrationLibraryItemID) REFERENCES dbo.IllustrationLibraryItems(IllustrationLibraryItemID);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.StoryIntelligenceIllustrationDemands', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.StoryIntelligenceIllustrationDemands
|
||||||
|
(
|
||||||
|
StoryIntelligenceIllustrationDemandID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceIllustrationDemands PRIMARY KEY,
|
||||||
|
ProjectID int NULL,
|
||||||
|
DemandKey nvarchar(240) NOT NULL,
|
||||||
|
AgeBand nvarchar(40) NOT NULL,
|
||||||
|
Presentation nvarchar(40) NOT NULL,
|
||||||
|
HairColour nvarchar(40) NOT NULL,
|
||||||
|
SkinTone nvarchar(40) NOT NULL,
|
||||||
|
HairLength nvarchar(40) NULL,
|
||||||
|
Glasses nvarchar(20) NULL,
|
||||||
|
FacialHair nvarchar(40) NULL,
|
||||||
|
RequestCount int NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_RequestCount DEFAULT 1,
|
||||||
|
LastRequestedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_LastRequestedUtc DEFAULT SYSUTCDATETIME(),
|
||||||
|
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_CreatedUtc DEFAULT SYSUTCDATETIME(),
|
||||||
|
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_UpdatedUtc DEFAULT SYSUTCDATETIME()
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryIntelligenceIllustrationDemands_ProjectDemand' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceIllustrationDemands'))
|
||||||
|
BEGIN
|
||||||
|
CREATE UNIQUE INDEX UX_StoryIntelligenceIllustrationDemands_ProjectDemand
|
||||||
|
ON dbo.StoryIntelligenceIllustrationDemands(ProjectID, DemandKey);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
@ -126,6 +126,12 @@ public sealed class StoryIntelligenceExperienceImageResolution
|
|||||||
public string? FinalImageUrl { get; init; }
|
public string? FinalImageUrl { get; init; }
|
||||||
public string FallbackImageUrl { get; init; } = string.Empty;
|
public string FallbackImageUrl { get; init; } = string.Empty;
|
||||||
public bool FallbackUsed { get; init; } = true;
|
public bool FallbackUsed { get; init; } = true;
|
||||||
|
public decimal? AssignmentConfidence { get; init; }
|
||||||
|
public int? MatchingScore { get; init; }
|
||||||
|
public string? RejectedCandidates { get; init; }
|
||||||
|
public string? ReasonChosen { get; init; }
|
||||||
|
public string? ReasonReassigned { get; init; }
|
||||||
|
public string? DemandStatus { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceExperienceRelationshipState
|
public sealed class StoryIntelligenceExperienceRelationshipState
|
||||||
|
|||||||
@ -263,7 +263,13 @@
|
|||||||
recordId: resolution.illustrationLibraryItemId || null,
|
recordId: resolution.illustrationLibraryItemId || null,
|
||||||
status: resolution.status || "fallback",
|
status: resolution.status || "fallback",
|
||||||
finalImageUrl: resolution.finalImageUrl || item?.imagePath || "",
|
finalImageUrl: resolution.finalImageUrl || item?.imagePath || "",
|
||||||
fallbackUsed: !!resolution.fallbackUsed
|
fallbackUsed: !!resolution.fallbackUsed,
|
||||||
|
assignmentConfidence: resolution.assignmentConfidence || null,
|
||||||
|
matchingScore: resolution.matchingScore || null,
|
||||||
|
reasonChosen: resolution.reasonChosen || "",
|
||||||
|
reasonReassigned: resolution.reasonReassigned || "",
|
||||||
|
rejectedCandidates: resolution.rejectedCandidates || "",
|
||||||
|
demandStatus: resolution.demandStatus || ""
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user