Phase 21D - Story Intelligence Illustration Library

This commit is contained in:
Nick Beckley 2026-07-11 23:33:15 +00:00
parent 57df5646e6
commit 07a5477034
18 changed files with 3092 additions and 11 deletions

View File

@ -1,4 +1,8 @@
using System.Text.Json;
using System.Net;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PlotLine.Models;
using PlotLine.Services;
@ -21,7 +25,18 @@ var tests = new (string Name, Action Test)[]
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys)
("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys),
("Illustration prompt builder separates spec and prompt", IllustrationPromptBuilderSeparatesSpecAndPrompt),
("Illustration specification validation catches missing typed fields", IllustrationSpecificationValidationCatchesMissingTypedFields),
("Illustration category validation rejects unknown values", IllustrationCategoryValidationRejectsUnknownValues),
("Illustration status transitions protect approval flow", IllustrationStatusTransitionsProtectApprovalFlow),
("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely),
("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts),
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel),
("Illustration provider omits GPT image response format", IllustrationProviderOmitsGptImageResponseFormat)
};
foreach (var test in tests)
@ -314,6 +329,190 @@ static string CanonicalKnowledgeKey(string statement)
return (string)method!.Invoke(null, [statement])!;
}
static void IllustrationPromptBuilderSeparatesSpecAndPrompt()
{
var spec = IllustrationStarterBatchDefinition.All.Single(x => x.Code == "asset-folded-letter");
var builder = new IllustrationPromptBuilder();
var result = builder.Build(spec);
Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded.");
Assert(result.Prompt.Contains("no visible text", StringComparison.OrdinalIgnoreCase), "Prompt did not include visual safety guidance.");
Assert(result.Prompt.Contains("Category composition rules", StringComparison.Ordinal), "Prompt did not include category composition rules.");
Assert(result.Prompt.Contains("Structured details", StringComparison.Ordinal), "Prompt did not include structured details.");
Assert(result.Prompt.Contains(spec.Code, StringComparison.Ordinal), "Prompt did not include stable code.");
Assert(result.SpecificationJson.Contains("\"code\": \"asset-folded-letter\"", StringComparison.Ordinal), "Specification JSON did not preserve the stable code.");
Assert(result.MetadataJson.Contains("generic-no-manuscript-data", StringComparison.Ordinal), "Metadata JSON did not preserve privacy metadata.");
}
static void IllustrationSpecificationValidationCatchesMissingTypedFields()
{
var invalid = new IllustrationGenerationSpecification
{
Category = IllustrationLibraryCategories.Character,
Code = "char-invalid",
Title = "Invalid",
Mood = "Calm"
};
var errors = invalid.Validate();
Assert(errors.Any(error => error.Contains("Apparent age band", StringComparison.OrdinalIgnoreCase)), "Character age-band validation should run.");
Assert(errors.Any(error => error.Contains("Presentation", StringComparison.OrdinalIgnoreCase)), "Character presentation validation should run.");
}
static void IllustrationCategoryValidationRejectsUnknownValues()
{
Assert(IllustrationLibraryCategories.IsValid(IllustrationLibraryCategories.Character), "Character should be valid.");
Assert(IllustrationLibraryCategories.IsValid(IllustrationLibraryCategories.Location), "Location should be valid.");
Assert(IllustrationLibraryCategories.IsValid(IllustrationLibraryCategories.Asset), "Asset should be valid.");
Assert(!IllustrationLibraryCategories.IsValid("Manuscript"), "Unknown category should be rejected.");
}
static void IllustrationStatusTransitionsProtectApprovalFlow()
{
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Planned, IllustrationLibraryStatuses.Queued), "Planned should queue.");
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Generating, IllustrationLibraryStatuses.Generated), "Generating should become generated.");
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Generated, IllustrationLibraryStatuses.Approved), "Generated should approve.");
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Approved, IllustrationLibraryStatuses.Superseded), "Approved should become superseded.");
Assert(!IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Planned, IllustrationLibraryStatuses.Approved), "Planned should not approve directly.");
Assert(!IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Queued, IllustrationLibraryStatuses.Approved), "Queued should not approve directly.");
}
static void IllustrationStorageCodesAreNormalisedSafely()
{
Assert(IllustrationLibraryStorageService.SafeSegment("Asset Letter_01.png") == "asset-letter-01-png", "Storage code was not normalised.");
var failed = false;
try
{
IllustrationLibraryStorageService.SafeSegment("../");
}
catch (ArgumentException)
{
failed = true;
}
Assert(failed, "Unsafe storage code should be rejected.");
}
static void IllustrationStarterBatchHasRequiredCategoryCounts()
{
Assert(IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Character) == 12, "Starter characters should total 12.");
Assert(IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Location) == 6, "Starter locations should total 6.");
Assert(IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Asset) == 10, "Starter assets should total 10.");
Assert(IllustrationStarterBatchDefinition.All.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() == IllustrationStarterBatchDefinition.All.Count, "Starter codes should be unique.");
}
static void IllustrationStarterGenerationPlanSkipsSuccessfulItems()
{
var generatedCharacter = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 1,
Category = IllustrationLibraryCategories.Character,
StableCode = "char-young-adult-brunette-observer",
Status = IllustrationLibraryStatuses.Generated,
UpdatedUtc = DateTime.UtcNow
};
var failedAsset = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 2,
Category = IllustrationLibraryCategories.Asset,
StableCode = "asset-folded-letter",
Status = IllustrationLibraryStatuses.Failed,
UpdatedUtc = DateTime.UtcNow
};
var plan = IllustrationStarterBatchDefinition.BuildGenerationPlan([generatedCharacter, failedAsset]);
Assert(plan.CharactersToGenerate == 11, $"Expected 11 characters after one generated item, got {plan.CharactersToGenerate}.");
Assert(plan.AssetsToGenerate == 10, $"Expected failed starter asset to remain eligible, got {plan.AssetsToGenerate}.");
Assert(!IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(generatedCharacter.StableCode, [generatedCharacter, failedAsset]), "Generated starter should not be eligible.");
Assert(IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(failedAsset.StableCode, [generatedCharacter, failedAsset]), "Failed starter should be eligible.");
}
static void IllustrationBulkRetryResultReportsCounts()
{
var result = new IllustrationBulkRetryResult(12, 4, 3);
Assert(result.Message.Contains("12 failed illustration", StringComparison.Ordinal), "Queued count should be reported.");
Assert(result.Message.Contains("4 skipped", StringComparison.Ordinal), "Skipped count should be reported.");
Assert(result.Message.Contains("3 reached the retry limit", StringComparison.Ordinal), "Retry-limit count should be reported.");
}
static void IllustrationBulkRetrySkipsBlockingDuplicateStableCodes()
{
var failed = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 27,
StableCode = "asset-photo-print",
Status = IllustrationLibraryStatuses.Failed,
IsActive = true,
ParentItemID = null
};
var planned = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 38,
StableCode = "asset-photo-print",
Status = IllustrationLibraryStatuses.Planned,
IsActive = true,
ParentItemID = null
};
var approved = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 55,
StableCode = "asset-photo-print",
Status = IllustrationLibraryStatuses.Approved,
IsActive = true,
ParentItemID = null
};
Assert(IllustrationLibraryService.ResolveBulkRetryTarget(failed, [failed, approved]) is null, "Failed duplicate should be skipped when an approved active sibling exists.");
Assert(IllustrationLibraryService.ResolveBulkRetryTarget(failed, [failed, planned])?.IllustrationLibraryItemID == planned.IllustrationLibraryItemID, "Failed duplicate should queue its planned active sibling.");
}
static void IllustrationProviderReportsMissingImageModel()
{
var provider = new OpenAIIllustrationImageProvider(
new HttpClient(),
Options.Create(new StoryIntelligenceOptions { ApiKey = "configured-for-test" }),
NullLogger<OpenAIIllustrationImageProvider>.Instance);
Assert(!provider.IsConfigured, "Provider should not be configured without an image model.");
Assert(provider.MissingConfiguration.Count == 1, "Only image model should be missing when API key exists.");
Assert(provider.MissingConfiguration[0].Contains("image-generation model", StringComparison.OrdinalIgnoreCase), "Missing image model should be named.");
}
static void IllustrationProviderOmitsGptImageResponseFormat()
{
string? payload = null;
var handler = new CaptureImageRequestHandler(request =>
{
payload = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent(
"""{"error":{"message":"test failure","code":"invalid_request_error"}}""",
Encoding.UTF8,
"application/json")
};
});
var provider = new OpenAIIllustrationImageProvider(
new HttpClient(handler),
Options.Create(new StoryIntelligenceOptions
{
ApiKey = "configured-for-test",
ImageGenerationModel = "gpt-image-1",
ImageGenerationSize = "1024x1024"
}),
NullLogger<OpenAIIllustrationImageProvider>.Instance);
var result = provider.GenerateAsync(
new IllustrationImageGenerationRequest(1, "asset-folded-letter", IllustrationLibraryCategories.Asset, "Prompt", IllustrationPromptBuilder.CurrentTemplateVersion),
CancellationToken.None).GetAwaiter().GetResult();
Assert(payload is not null, "Provider did not send a payload.");
Assert(!payload!.Contains("response_format", StringComparison.Ordinal), "GPT image payload should not include response_format.");
Assert(payload.Contains("\"model\":\"gpt-image-1\"", StringComparison.Ordinal), "Payload should include the configured image model.");
Assert(result.ErrorMessage?.Contains("invalid_request_error", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error code.");
Assert(result.ErrorMessage?.Contains("test failure", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error message.");
}
static JsonSerializerOptions JsonOptions()
=> new()
{
@ -328,3 +527,9 @@ static void Assert(bool condition, string message)
throw new InvalidOperationException(message);
}
}
internal sealed class CaptureImageRequestHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(respond(request));
}

View File

@ -19,7 +19,8 @@ public sealed class AdminController(
IStoryIntelligenceResultPersistenceService storyIntelligenceResults,
IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
IStoryIntelligenceFullChapterTestService fullChapterTest,
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit) : Controller
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
IIllustrationLibraryService illustrationLibrary) : Controller
{
[HttpGet("")]
[HttpGet("index")]
@ -37,6 +38,121 @@ public sealed class AdminController(
return View();
}
[HttpGet("story-intelligence/illustration-library")]
public async Task<IActionResult> StoryIntelligenceIllustrationLibrary([FromQuery] IllustrationLibraryFilter filter)
{
return View(await illustrationLibrary.BuildAdminViewModelAsync(filter));
}
[HttpPost("story-intelligence/illustration-library/starter-plan")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateIllustrationStarterPlan()
{
var result = await illustrationLibrary.EnsureStarterPlanAsync();
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary));
}
[HttpPost("story-intelligence/illustration-library/generate-starter")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> GenerateIllustrationStarterLibrary(IllustrationStarterGenerationForm form)
{
var result = await illustrationLibrary.GenerateStarterLibraryAsync(form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary));
}
[HttpPost("story-intelligence/illustration-library/queue")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> QueueIllustrationLibraryItems(IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.QueueAsync(null, form.Category, form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), new { category = form.Category });
}
[HttpPost("story-intelligence/illustration-library/requeue-failed")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RequeueFailedIllustrationLibraryItems(IllustrationLibraryBulkRetryForm form)
{
var result = await illustrationLibrary.RequeueAllFailedAsync();
TempData[result.QueuedCount > 0 ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), new { category = form.Category, status = form.Status, search = form.Search });
}
[HttpPost("story-intelligence/illustration-library/{id:int}/queue")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> QueueIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.QueueAsync(id, null, form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/approve")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ApproveIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
var result = await illustrationLibrary.ApproveAsync(id, userId);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/reject")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RejectIllustrationLibraryItem(int id, IllustrationLibraryRejectForm form)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
var result = await illustrationLibrary.RejectAsync(id, userId, form.Reason);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/regenerate")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegenerateIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.RegenerateAsync(id, form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/disable")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DisableIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.DisableAsync(id);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/metadata")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateIllustrationLibraryMetadata(int id, IllustrationLibraryMetadataForm form)
{
var result = await illustrationLibrary.UpdateMetadataAsync(id, form);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
private static object PreserveIllustrationLibraryFilter(IllustrationLibraryQueueForm form)
=> new { category = form.Category, status = form.Status, search = form.Search };
private static object PreserveIllustrationLibraryFilter(IllustrationLibraryRejectForm form)
=> new { category = form.Category, status = form.Status, search = form.Search };
private static object PreserveIllustrationLibraryFilter(IllustrationLibraryMetadataForm form)
=> new { category = form.Category, status = form.Status, search = form.Search };
[HttpGet("story-intelligence-diagnostics")]
public async Task<IActionResult> StoryIntelligenceDiagnostics(CancellationToken cancellationToken)
{

View File

@ -6,16 +6,20 @@ namespace PlotLine.Controllers;
[Authorize(Policy = "AdminOnly")]
[Route("Development")]
public sealed class DevelopmentController(IWebHostEnvironment environment) : Controller
public sealed class DevelopmentController(
IWebHostEnvironment environment,
IIllustrationLibraryService illustrationLibrary) : Controller
{
[HttpGet("StoryIntelligenceExperience")]
public IActionResult StoryIntelligenceExperience()
public async Task<IActionResult> StoryIntelligenceExperience()
{
if (!environment.IsDevelopment())
{
return NotFound();
}
return View(StoryIntelligenceExperiencePrototypeData.Build());
var model = StoryIntelligenceExperiencePrototypeData.Build();
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
return View(model);
}
}

View File

@ -0,0 +1,214 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IIllustrationLibraryRepository
{
Task<IReadOnlyList<IllustrationLibraryItem>> ListAsync(IllustrationLibraryFilter filter);
Task<IllustrationLibrarySummary> GetSummaryAsync();
Task<IllustrationLibraryItem?> GetAsync(int illustrationLibraryItemId);
Task<IllustrationLibraryItem> UpsertPlannedAsync(IllustrationGenerationSpecification specification, IllustrationPromptBuildResult prompt);
Task<int> QueueAsync(int? illustrationLibraryItemId, string? category);
Task<IllustrationLibraryItem?> ClaimNextAsync();
Task MarkGeneratedAsync(int illustrationLibraryItemId, IllustrationStoredImage image, IllustrationImageGenerationResult providerResult, string prompt, string templateVersion);
Task MarkFailedAsync(int illustrationLibraryItemId, string errorMessage);
Task ApproveAsync(int illustrationLibraryItemId, int userId);
Task RejectAsync(int illustrationLibraryItemId, int userId, string? reason);
Task<IllustrationLibraryItem?> CreateRegenerationAsync(int illustrationLibraryItemId, string? adminNotes);
Task DisableAsync(int illustrationLibraryItemId);
Task UpdateMetadataAsync(int illustrationLibraryItemId, string displayTitle, string? shortDescription, string? metadataJson, string? adminNotes);
Task<IReadOnlyList<IllustrationLibraryItem>> GetApprovedAsync();
Task<int> SupersedeRecoveredFailuresAsync();
}
public sealed class IllustrationLibraryRepository(ISqlConnectionFactory connectionFactory) : IIllustrationLibraryRepository
{
public async Task<IReadOnlyList<IllustrationLibraryItem>> ListAsync(IllustrationLibraryFilter filter)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_List",
new
{
Category = string.IsNullOrWhiteSpace(filter.Category) ? null : filter.Category,
Status = string.IsNullOrWhiteSpace(filter.Status) ? null : filter.Status,
Search = string.IsNullOrWhiteSpace(filter.Search) ? null : filter.Search
},
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<IllustrationLibrarySummary> GetSummaryAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<IllustrationLibrarySummary>(
"dbo.IllustrationLibraryItem_Summary",
commandType: CommandType.StoredProcedure);
}
public async Task<IllustrationLibraryItem?> GetAsync(int illustrationLibraryItemId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_Get",
new { IllustrationLibraryItemID = illustrationLibraryItemId },
commandType: CommandType.StoredProcedure);
}
public async Task<IllustrationLibraryItem> UpsertPlannedAsync(IllustrationGenerationSpecification specification, IllustrationPromptBuildResult prompt)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_UpsertPlanned",
new
{
Category = specification.Category,
StableCode = specification.Code,
DisplayTitle = specification.Title,
ShortDescription = specification.Description,
MetadataJson = prompt.MetadataJson,
SpecificationJson = prompt.SpecificationJson
},
commandType: CommandType.StoredProcedure);
}
public async Task<int> QueueAsync(int? illustrationLibraryItemId, string? category)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.IllustrationLibraryItem_Queue",
new { IllustrationLibraryItemID = illustrationLibraryItemId, Category = category },
commandType: CommandType.StoredProcedure);
}
public async Task<IllustrationLibraryItem?> ClaimNextAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_ClaimNext",
commandType: CommandType.StoredProcedure);
}
public async Task MarkGeneratedAsync(int illustrationLibraryItemId, IllustrationStoredImage image, IllustrationImageGenerationResult providerResult, string prompt, string templateVersion)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_MarkGenerated",
new
{
IllustrationLibraryItemID = illustrationLibraryItemId,
image.FilePath,
image.ThumbnailPath,
image.Width,
image.Height,
image.FileSizeBytes,
image.MimeType,
Provider = providerResult.Provider,
Model = providerResult.Model,
PromptText = prompt,
PromptTemplateVersion = templateVersion,
ProviderRequestID = providerResult.ProviderRequestID
},
commandType: CommandType.StoredProcedure);
}
public async Task MarkFailedAsync(int illustrationLibraryItemId, string errorMessage)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_MarkFailed",
new { IllustrationLibraryItemID = illustrationLibraryItemId, LastError = errorMessage },
commandType: CommandType.StoredProcedure);
}
public async Task ApproveAsync(int illustrationLibraryItemId, int userId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_Approve",
new { IllustrationLibraryItemID = illustrationLibraryItemId, ApprovedByUserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task RejectAsync(int illustrationLibraryItemId, int userId, string? reason)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_Reject",
new { IllustrationLibraryItemID = illustrationLibraryItemId, RejectedByUserID = userId, RejectionReason = reason },
commandType: CommandType.StoredProcedure);
}
public async Task<IllustrationLibraryItem?> CreateRegenerationAsync(int illustrationLibraryItemId, string? adminNotes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_CreateRegeneration",
new { IllustrationLibraryItemID = illustrationLibraryItemId, AdminNotes = adminNotes },
commandType: CommandType.StoredProcedure);
}
public async Task DisableAsync(int illustrationLibraryItemId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_Disable",
new { IllustrationLibraryItemID = illustrationLibraryItemId },
commandType: CommandType.StoredProcedure);
}
public async Task UpdateMetadataAsync(int illustrationLibraryItemId, string displayTitle, string? shortDescription, string? metadataJson, string? adminNotes)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_UpdateMetadata",
new
{
IllustrationLibraryItemID = illustrationLibraryItemId,
DisplayTitle = displayTitle,
ShortDescription = shortDescription,
MetadataJson = metadataJson,
AdminNotes = adminNotes
},
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<IllustrationLibraryItem>> GetApprovedAsync()
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_GetApproved",
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<int> SupersedeRecoveredFailuresAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.ExecuteAsync(
"""
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
UPDATE failed
SET Status = N'Superseded',
IsActive = 0,
UpdatedUtc = SYSUTCDATETIME(),
AdminNotes = CONCAT(ISNULL(NULLIF(failed.AdminNotes, N''), N''), CASE WHEN NULLIF(failed.AdminNotes, N'') IS NULL THEN N'' ELSE N' ' END, N'Superseded after a replacement illustration generated successfully.')
FROM dbo.IllustrationLibraryItems failed
WHERE failed.Status = N'Failed'
AND failed.IsActive = 1
AND EXISTS
(
SELECT 1
FROM dbo.IllustrationLibraryItems replacement
WHERE replacement.IllustrationLibraryItemID <> failed.IllustrationLibraryItemID
AND replacement.StableCode = failed.StableCode
AND replacement.IsActive = 1
AND replacement.Status IN (N'Generated', N'Approved')
);
""");
}
}

View File

@ -0,0 +1,263 @@
using System.Text.Json.Serialization;
namespace PlotLine.Models;
public static class IllustrationLibraryCategories
{
public const string Character = "Character";
public const string Location = "Location";
public const string Asset = "Asset";
public static readonly IReadOnlyList<string> All = [Character, Location, Asset];
public static bool IsValid(string? category)
=> All.Any(item => string.Equals(item, category, StringComparison.OrdinalIgnoreCase));
public static string StorageFolder(string category)
=> category switch
{
Character => "characters",
Location => "locations",
Asset => "assets",
_ => throw new ArgumentOutOfRangeException(nameof(category), category, "Unknown illustration category.")
};
}
public static class IllustrationLibraryStatuses
{
public const string Planned = "Planned";
public const string Queued = "Queued";
public const string Generating = "Generating";
public const string Generated = "Generated";
public const string Approved = "Approved";
public const string Rejected = "Rejected";
public const string Failed = "Failed";
public const string Superseded = "Superseded";
public const string Disabled = "Disabled";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Planned,
Queued,
Generating,
Generated,
Approved,
Rejected,
Failed,
Superseded,
Disabled
};
public static bool IsValid(string? status)
=> !string.IsNullOrWhiteSpace(status) && All.Contains(status);
public static bool CanTransition(string from, string to)
=> (from, to) switch
{
(Planned, Queued) => true,
(Queued, Generating) => true,
(Generating, Generated) => true,
(Generating, Failed) => true,
(Generated, Approved) => true,
(Generated, Rejected) => true,
(Approved, Superseded) => true,
(Rejected, Queued) => true,
(Failed, Queued) => true,
(Approved, Disabled) => true,
(Disabled, Queued) => true,
_ when string.Equals(from, to, StringComparison.OrdinalIgnoreCase) => true,
_ => false
};
}
public sealed class IllustrationGenerationSpecification
{
public string Category { get; init; } = string.Empty;
public string Code { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string VisualSubject { get; init; } = string.Empty;
public string Composition { get; init; } = string.Empty;
public string Mood { get; init; } = string.Empty;
public string? ApparentAgeBand { get; init; }
public string? Presentation { get; init; }
public string? HairColour { get; init; }
public string? HairLength { get; init; }
public string? SkinTone { get; init; }
public string? ClothingEra { get; init; }
public string? ClothingStyle { get; init; }
public string? LocationType { get; init; }
public string? Era { get; init; }
public string? Setting { get; init; }
public string? ObjectType { get; init; }
public string? Colour { get; init; }
public IReadOnlyList<string> Features { get; init; } = [];
public IReadOnlyList<string> Palette { get; init; } = [];
public IReadOnlyList<string> MetadataTags { get; init; } = [];
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (!IllustrationLibraryCategories.IsValid(Category))
{
errors.Add("Category must be Character, Location or Asset.");
}
if (string.IsNullOrWhiteSpace(Code))
{
errors.Add("Stable code is required.");
}
if (string.IsNullOrWhiteSpace(Title))
{
errors.Add("Title is required.");
}
if (string.IsNullOrWhiteSpace(Mood))
{
errors.Add("Mood is required.");
}
if (Category == IllustrationLibraryCategories.Character)
{
Require(ApparentAgeBand, "Apparent age band is required for characters.");
Require(Presentation, "Presentation is required for characters.");
}
else if (Category == IllustrationLibraryCategories.Location)
{
Require(LocationType, "Location type is required for locations.");
}
else if (Category == IllustrationLibraryCategories.Asset)
{
Require(ObjectType, "Object type is required for assets.");
}
return errors;
void Require(string? value, string message)
{
if (string.IsNullOrWhiteSpace(value))
{
errors.Add(message);
}
}
}
}
public sealed class IllustrationLibraryItem
{
public int IllustrationLibraryItemID { get; set; }
public string Category { get; set; } = string.Empty;
public string StableCode { get; set; } = string.Empty;
public string DisplayTitle { get; set; } = string.Empty;
public string? ShortDescription { get; set; }
public string? MetadataJson { get; set; }
public string? SpecificationJson { get; set; }
public string? FilePath { get; set; }
public string? ThumbnailPath { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public long? FileSizeBytes { get; set; }
public string? MimeType { get; set; }
public string Status { get; set; } = IllustrationLibraryStatuses.Planned;
public bool IsActive { get; set; }
public bool IsApproved { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
public DateTime? ApprovedUtc { get; set; }
public int? ApprovedByUserID { get; set; }
public string? Provider { get; set; }
public string? Model { get; set; }
public string? PromptText { get; set; }
public string? Prompt { get; set; }
public string? PromptTemplateVersion { get; set; }
public string? ProviderRequestID { get; set; }
public int AttemptCount { get; set; }
public int GenerationAttemptCount { get; set; }
public int? ParentItemID { get; set; }
public string? RejectionReason { get; set; }
public DateTime? RejectedUtc { get; set; }
public int? RejectedByUserID { get; set; }
public string? AdminNotes { get; set; }
public int UsageCount { get; set; }
public string? LastError { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? QueuedUtc { get; set; }
public DateTime? StartedUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
}
public sealed class IllustrationLibrarySummary
{
public int TotalCount { get; set; }
public int PlannedCount { get; set; }
public int QueuedCount { get; set; }
public int GeneratingCount { get; set; }
public int GeneratedCount { get; set; }
public int ApprovedCount { get; set; }
public int RejectedCount { get; set; }
public int FailedCount { get; set; }
public int SupersededCount { get; set; }
public int DisabledCount { get; set; }
}
public sealed class IllustrationLibraryFilter
{
public string? Category { get; init; }
public string? Status { get; init; }
public string? Search { get; init; }
}
public sealed record IllustrationPromptBuildResult(
string Prompt,
string TemplateVersion,
string SpecificationJson,
string MetadataJson);
public sealed record IllustrationImageGenerationRequest(
int IllustrationLibraryItemID,
string StableCode,
string Category,
string Prompt,
string TemplateVersion);
public sealed record IllustrationImageGenerationResult(
bool Succeeded,
byte[]? ImageBytes = null,
string? MimeType = null,
string? Provider = null,
string? Model = null,
string? ProviderRequestID = null,
string? ErrorMessage = null)
{
[JsonIgnore]
public bool HasImage => ImageBytes is { Length: > 0 };
}
public sealed record IllustrationStoredImage(
string FilePath,
string ThumbnailPath,
int Width,
int Height,
long FileSizeBytes,
string MimeType);
public sealed record IllustrationLibraryActionResult(bool Succeeded, string Message);
public sealed record IllustrationBulkRetryResult(
int QueuedCount,
int SkippedCount,
int RetryLimitReachedCount)
{
public string Message
=> $"{QueuedCount:N0} failed illustration(s) queued. {SkippedCount:N0} skipped. {RetryLimitReachedCount:N0} reached the retry limit.";
}
public sealed record IllustrationStarterGenerationPlan(
int CharactersToGenerate,
int LocationsToGenerate,
int AssetsToGenerate)
{
public int TotalToGenerate => CharactersToGenerate + LocationsToGenerate + AssetsToGenerate;
}

View File

@ -99,6 +99,8 @@ public sealed class StoryIntelligenceOptions
public string Model { get; init; } = string.Empty;
public string ChapterStructureModel { get; init; } = string.Empty;
public string SceneIntelligenceModel { get; init; } = string.Empty;
public string ImageGenerationModel { get; init; } = string.Empty;
public string ImageGenerationSize { get; init; } = "1024x1024";
public int MaxOutputTokens { get; init; } = 4000;
public int? SceneIntelligenceMaxOutputTokens { get; init; }
public int TimeoutSeconds { get; init; } = 120;

View File

@ -144,6 +144,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
@ -216,6 +217,11 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
builder.Services.AddScoped<IIllustrationPromptBuilder, IllustrationPromptBuilder>();
builder.Services.AddScoped<IIllustrationLibraryStorageService, IllustrationLibraryStorageService>();
builder.Services.AddHttpClient<IIllustrationImageProvider, OpenAIIllustrationImageProvider>();
builder.Services.AddScoped<IIllustrationGenerationRunner, IllustrationGenerationRunner>();
builder.Services.AddScoped<IIllustrationLibraryService, IllustrationLibraryService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
@ -232,6 +238,7 @@ public class Program
builder.Services.AddHostedService<EmailQueueWorker>();
builder.Services.AddHostedService<StoryIntelligenceWorker>();
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
builder.Services.AddHostedService<IllustrationGenerationWorker>();
var app = builder.Build();

View File

@ -0,0 +1,927 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
using SkiaSharp;
namespace PlotLine.Services;
public interface IIllustrationPromptBuilder
{
IllustrationPromptBuildResult Build(IllustrationGenerationSpecification specification);
}
public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
{
public const string CurrentTemplateVersion = "21D.1";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public IllustrationPromptBuildResult Build(IllustrationGenerationSpecification specification)
{
var errors = specification.Validate();
if (errors.Count > 0)
{
throw new ArgumentException(string.Join(" ", errors), nameof(specification));
}
var metadataJson = JsonSerializer.Serialize(specification.Metadata, JsonOptions);
var specificationJson = JsonSerializer.Serialize(specification, JsonOptions);
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 typedDetails = TypedDetails(specification);
var categoryRules = CategoryRules(specification.Category);
var prompt = $"""
Create a high-quality editorial illustration for PlotDirector's reusable Story Intelligence illustration library.
Category: {specification.Category}
Library code: {specification.Code}
Title: {specification.Title}
Subject: {specification.VisualSubject}
Structured details: {typedDetails}
Description: {specification.Description}
Composition: {specification.Composition}
Mood: {specification.Mood}
Palette guidance: {palette}
Metadata tags: {tags}
Category composition rules: {categoryRules}
Requirements:
- representative visual identifier only, not a canonical depiction of any manuscript
- editorial illustration, artistic sketch or painted concept art, stylised realism
- sophisticated and calm, suitable for a dark navy interface
- calm cinematic lighting and clear silhouette
- no visible text, captions, logos, watermarks, UI, signatures, or typography
- no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim
- avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters
- square composition that still reads clearly as a small thumbnail
""";
return new(prompt, CurrentTemplateVersion, specificationJson, metadataJson);
}
private static string CategoryRules(string category)
=> category switch
{
IllustrationLibraryCategories.Character => "Square image; head-and-shoulders or upper body; one person only; face large and readable; subdued background; suitable for circular cropping.",
IllustrationLibraryCategories.Location => "One clear focal environment; usable in circular and landscape crops; no prominent people; no text or readable signage.",
IllustrationLibraryCategories.Asset => "Square image; one dominant object; recognisable silhouette; clean subdued background; entire object visible where practical.",
_ => string.Empty
};
private static string TypedDetails(IllustrationGenerationSpecification specification)
=> specification.Category switch
{
IllustrationLibraryCategories.Character => string.Join("; ", new[]
{
$"apparent age band {specification.ApparentAgeBand}",
$"presentation {specification.Presentation}",
$"hair {specification.HairLength} {specification.HairColour}",
$"skin tone range {specification.SkinTone}",
$"features {string.Join(", ", specification.Features)}",
$"clothing {specification.ClothingEra} {specification.ClothingStyle}"
}.Where(item => !item.EndsWith(" ", StringComparison.Ordinal))),
IllustrationLibraryCategories.Location => $"location type {specification.LocationType}; era {specification.Era}; setting {specification.Setting}",
IllustrationLibraryCategories.Asset => $"object type {specification.ObjectType}; colour {specification.Colour}; era {specification.Era}",
_ => string.Empty
};
}
public static class IllustrationStarterBatchDefinition
{
public static IReadOnlyList<IllustrationGenerationSpecification> All { get; } =
[
Character("char-young-adult-brunette-observer", "Young observer", "A young adult with dark hair, thoughtful expression and practical coat.", "three-quarter portrait with soft rim light", "watchful, resilient", ["teal shadows", "warm amber accent"]),
Character("char-young-adult-red-haired-witness", "Red-haired witness", "A young adult with auburn hair and guarded warmth.", "portrait facing slightly away with alert eyes", "protective, uncertain", ["midnight blue", "copper", "muted rose"]),
Character("char-middle-aged-weathered-man", "Weathered man", "A middle-aged man with lined features and controlled tension.", "close portrait, collar raised, low directional light", "withheld, imposing", ["charcoal", "steel blue", "dull gold"]),
Character("char-older-soft-presence", "Older remembered presence", "An older woman with gentle features and a private sadness.", "soft portrait like a remembered photograph", "warm, elegiac", ["soft grey", "dusty green", "warm ivory"]),
Character("char-professional-investigator", "Professional investigator", "A composed adult investigator with notebook and steady focus.", "waist-up portrait, neutral background", "analytical, calm", ["navy", "cream", "oxide red"]),
Character("char-neighbourhood-friend", "Neighbourhood friend", "A friendly neighbour figure with practical clothes and open posture.", "casual portrait in corridor light", "helpful, cautious", ["sage", "denim", "soft yellow"]),
Character("char-family-relative", "Family relative", "A relative figure carrying old emotional context.", "portrait with subdued domestic background", "familial, layered", ["plum", "moss", "paper white"]),
Character("char-quiet-antagonist", "Quiet antagonist", "A controlled figure whose stillness suggests pressure.", "shadowed portrait with crisp edge light", "tense, unreadable", ["black", "deep green", "brass"]),
Character("char-young-helper", "Young helper", "A younger helper figure with attentive expression.", "bright side-lit portrait", "curious, supportive", ["sky blue", "warm grey", "coral"]),
Character("char-authorial-voice", "Recorded voice", "A character represented as memory and voice rather than direct presence.", "portrait dissolving into abstract sound-like light", "intimate, distant", ["indigo", "silver", "warm white"]),
Character("char-archival-contact", "Archival contact", "An older archive or records contact with patient authority.", "portrait with shelves suggested behind", "knowledgeable, reserved", ["umber", "slate", "muted gold"]),
Character("char-unknown-figure", "Unknown figure", "A partly obscured person used for unresolved identity.", "silhouette with soft facial detail, no menace caricature", "ambiguous, suspenseful", ["deep blue", "smoke grey", "amber"]),
Location("loc-estate-house-exterior", "Estate house exterior", "A rain-darkened old house or converted estate building.", "wide square view with lit windows and wet path", "quiet, secretive", ["blue black", "window gold", "wet stone"]),
Location("loc-small-flat-interior", "Small flat interior", "A modest lived-in flat with signs of careful searching.", "room corner with desk, lamp and disturbed papers", "intimate, investigative", ["olive", "warm paper", "deep blue"]),
Location("loc-laundry-utility-room", "Laundry utility room", "A low-lit utility or laundry room with domestic machinery.", "central washer, small window, practical clutter", "private, compressed", ["cool blue", "ceramic white", "rust accent"]),
Location("loc-narrow-stairwell", "Narrow stairwell", "A cramped stairwell or landing inside an older building.", "looking down the stairs with rail shadows", "pressured, transitional", ["slate", "old cream", "sodium amber"]),
Location("loc-wet-car-park", "Wet car park", "A small wet car park with one clear exit route.", "low view of wet tarmac and lights", "exposed, tense", ["asphalt", "red reflection", "cold blue"]),
Location("loc-domestic-desk", "Domestic desk", "A desk or table where clues are assembled.", "top-down three-quarter view of desk surface", "focused, revelatory", ["walnut", "paper white", "lamp gold"]),
Asset("asset-folded-letter", "Folded letter", "A folded handwritten-looking letter as generic evidence.", "single object on table, envelope nearby, no readable text", "decisive, delicate", ["paper cream", "sepia", "shadow blue"]),
Asset("asset-worn-notebook", "Worn notebook", "A worn notebook with a torn page edge.", "notebook angled on dark surface, blank pages", "investigative, intimate", ["brown leather", "cream paper", "blue shadow"]),
Asset("asset-red-classic-car", "Red classic car", "A small red classic sports car used as a reusable vehicle asset.", "three-quarter view with wet reflections, no badges or plates", "charged, mobile", ["deep red", "chrome grey", "night blue"]),
Asset("asset-old-keys", "Old keys", "A small bunch of old keys on a tag.", "close object study with strong silhouette", "access, possibility", ["brass", "graphite", "warm white"]),
Asset("asset-rucksack", "Rucksack", "A moved rucksack or soft bag.", "bag on floor near doorway", "recent movement, absence", ["canvas green", "brown", "cool shadow"]),
Asset("asset-phone-recorder", "Phone recording", "A phone or recorder playing an audio message.", "device glowing on table, no visible text", "intimate, urgent", ["black glass", "blue glow", "warm tabletop"]),
Asset("asset-torn-page", "Torn page", "A torn page fragment with no readable writing.", "paper fragment under lamplight", "partial evidence", ["paper white", "sepia", "ink blue"]),
Asset("asset-locked-box", "Locked box", "A small locked keepsake or document box.", "box with latch in soft light", "contained secret", ["dark wood", "brass", "cool grey"]),
Asset("asset-photo-print", "Photo print", "A generic photograph print with indistinct figures.", "photo on desk with intentionally unreadable detail", "memory, proof", ["faded colour", "cream border", "shadow"]),
Asset("asset-map-pin", "Map clue", "A simple map or route clue with no readable labels.", "folded map with pin-like marker, no text", "connection, route", ["muted green", "paper tan", "red accent"])
];
public static IReadOnlySet<string> StarterCodes { get; } = All.Select(item => item.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
public static IllustrationStarterGenerationPlan BuildGenerationPlan(IReadOnlyList<IllustrationLibraryItem> existingItems)
{
var byCode = existingItems
.Where(item => StarterCodes.Contains(item.StableCode))
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
var characters = CountEligible(IllustrationLibraryCategories.Character);
var locations = CountEligible(IllustrationLibraryCategories.Location);
var assets = CountEligible(IllustrationLibraryCategories.Asset);
return new(characters, locations, assets);
int CountEligible(string category)
=> All.Count(specification =>
{
if (!string.Equals(specification.Category, category, StringComparison.Ordinal))
{
return false;
}
if (!byCode.TryGetValue(specification.Code, out var existing))
{
return true;
}
if (existing.Any(IsSuccessfulOrRunning))
{
return false;
}
return existing.Any(item => item.Status is IllustrationLibraryStatuses.Planned or IllustrationLibraryStatuses.Failed);
});
}
public static bool IsStarterCodeEligibleForGeneration(string code, IReadOnlyList<IllustrationLibraryItem> existingItems)
{
var matching = existingItems.Where(item => StarterCodes.Contains(item.StableCode) && string.Equals(item.StableCode, code, StringComparison.OrdinalIgnoreCase)).ToList();
return matching.Count == 0
|| (!matching.Any(IsSuccessfulOrRunning) && matching.Any(item => item.Status is IllustrationLibraryStatuses.Planned or IllustrationLibraryStatuses.Failed));
}
private static bool IsSuccessfulOrRunning(IllustrationLibraryItem item)
=> item.Status is IllustrationLibraryStatuses.Generated or IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Queued or IllustrationLibraryStatuses.Generating;
private static IllustrationGenerationSpecification Character(string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette)
=> Spec(IllustrationLibraryCategories.Character, code, title, subject, composition, mood, palette, ["character", "representative"]);
private static IllustrationGenerationSpecification Location(string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette)
=> Spec(IllustrationLibraryCategories.Location, code, title, subject, composition, mood, palette, ["location", "representative"]);
private static IllustrationGenerationSpecification Asset(string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette)
=> Spec(IllustrationLibraryCategories.Asset, code, title, subject, composition, mood, palette, ["asset", "representative"]);
private static IllustrationGenerationSpecification Spec(string category, string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette, IReadOnlyList<string> tags)
{
var shared = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["libraryPurpose"] = "Story Intelligence representative identifier",
["privacy"] = "generic-no-manuscript-data",
["style"] = "editorial-stylised-realism"
};
var common = new IllustrationGenerationSpecification
{
Category = category,
Code = code,
Title = title,
Description = "Representative visual identifier for Story Intelligence. Not canonical to any manuscript.",
VisualSubject = subject,
Composition = composition,
Mood = mood,
Palette = palette,
MetadataTags = tags,
Metadata = shared
};
return category switch
{
IllustrationLibraryCategories.Character => WithCharacter(common, code),
IllustrationLibraryCategories.Location => WithLocation(common, code),
IllustrationLibraryCategories.Asset => WithAsset(common, code),
_ => common
};
static IllustrationGenerationSpecification WithCharacter(IllustrationGenerationSpecification source, string code)
=> code switch
{
"char-young-adult-red-haired-witness" => Copy(source, apparentAgeBand: "YoungAdult", presentation: "Feminine", hairColour: "Red", hairLength: "Long", skinTone: "Light", features: ["Freckles"], clothingEra: "1980s", clothingStyle: "Casual"),
"char-middle-aged-weathered-man" => Copy(source, apparentAgeBand: "MiddleAged", presentation: "Masculine", hairColour: "DarkBrown", hairLength: "Short", skinTone: "LightMedium", features: ["LinedFace"], clothingEra: "1980s", clothingStyle: "SmartCasual"),
"char-older-soft-presence" => Copy(source, apparentAgeBand: "Older", presentation: "Feminine", hairColour: "Grey", hairLength: "Short", skinTone: "Light", features: ["GentleExpression"], clothingEra: "1980s", clothingStyle: "Domestic"),
"char-young-helper" => Copy(source, apparentAgeBand: "Teen", presentation: "Androgynous", hairColour: "Black", hairLength: "Short", skinTone: "Deep", features: ["BrightEyes"], clothingEra: "1980s", clothingStyle: "Casual"),
"char-archival-contact" => Copy(source, apparentAgeBand: "Older", presentation: "Masculine", hairColour: "White", hairLength: "Short", skinTone: "Medium", features: ["Glasses"], clothingEra: "1980s", clothingStyle: "Professional"),
_ => Copy(source, apparentAgeBand: "YoungAdult", presentation: "Feminine", hairColour: "Brown", hairLength: "Medium", skinTone: "Medium", features: ["ThoughtfulExpression"], clothingEra: "1980s", clothingStyle: "Casual")
};
static IllustrationGenerationSpecification WithLocation(IllustrationGenerationSpecification source, string code)
=> code switch
{
"loc-estate-house-exterior" => Copy(source, locationType: "DetachedHouse", era: "1980s", setting: "Suburban"),
"loc-small-flat-interior" => Copy(source, locationType: "ModestFlatInterior", era: "1980s", setting: "Urban"),
"loc-laundry-utility-room" => Copy(source, locationType: "LaundryUtilityRoom", era: "1980s", setting: "Domestic"),
"loc-wet-car-park" => Copy(source, locationType: "CarPark", era: "1980s", setting: "Urban"),
"loc-domestic-desk" => Copy(source, locationType: "Kitchen", era: "1980s", setting: "Domestic"),
_ => Copy(source, locationType: "UrbanStreet", era: "1980s", setting: "Urban")
};
static IllustrationGenerationSpecification WithAsset(IllustrationGenerationSpecification source, string code)
=> code switch
{
"asset-folded-letter" => Copy(source, objectType: "Letter", colour: "Cream", era: "1980s"),
"asset-worn-notebook" => Copy(source, objectType: "Notebook", colour: "Brown", era: "1980s"),
"asset-red-classic-car" => Copy(source, objectType: "ClassicSportsCar", colour: "Red", era: "1970s"),
"asset-old-keys" => Copy(source, objectType: "Keys", colour: "Brass", era: "1980s"),
"asset-rucksack" => Copy(source, objectType: "Rucksack", colour: "Green", era: "1980s"),
"asset-phone-recorder" => Copy(source, objectType: "Telephone", colour: "Black", era: "1980s"),
"asset-photo-print" => Copy(source, objectType: "Photograph", colour: "FadedColour", era: "1980s"),
"asset-locked-box" => Copy(source, objectType: "JewelleryBox", colour: "DarkWood", era: "1980s"),
"asset-torn-page" => Copy(source, objectType: "OfficialDocument", colour: "Cream", era: "1980s"),
_ => Copy(source, objectType: "Suitcase", colour: "Brown", era: "1980s")
};
static IllustrationGenerationSpecification Copy(
IllustrationGenerationSpecification source,
string? apparentAgeBand = null,
string? presentation = null,
string? hairColour = null,
string? hairLength = null,
string? skinTone = null,
IReadOnlyList<string>? features = null,
string? clothingEra = null,
string? clothingStyle = null,
string? locationType = null,
string? era = null,
string? setting = null,
string? objectType = null,
string? colour = null)
=> new()
{
Category = source.Category,
Code = source.Code,
Title = source.Title,
Description = source.Description,
VisualSubject = source.VisualSubject,
Composition = source.Composition,
Mood = source.Mood,
Palette = source.Palette,
MetadataTags = source.MetadataTags,
Metadata = source.Metadata,
ApparentAgeBand = apparentAgeBand,
Presentation = presentation,
HairColour = hairColour,
HairLength = hairLength,
SkinTone = skinTone,
Features = features ?? [],
ClothingEra = clothingEra,
ClothingStyle = clothingStyle,
LocationType = locationType,
Era = era,
Setting = setting,
ObjectType = objectType,
Colour = colour
};
}
}
public interface IIllustrationImageProvider
{
string ProviderName { get; }
string EffectiveModel { get; }
bool IsConfigured { get; }
IReadOnlyList<string> MissingConfiguration { get; }
Task<IllustrationImageGenerationResult> GenerateAsync(IllustrationImageGenerationRequest request, CancellationToken cancellationToken);
}
public sealed class OpenAIIllustrationImageProvider(
HttpClient httpClient,
IOptions<StoryIntelligenceOptions> options,
ILogger<OpenAIIllustrationImageProvider> logger) : IIllustrationImageProvider
{
private readonly StoryIntelligenceOptions _options = options.Value;
public string ProviderName => "OpenAI";
public string EffectiveModel => string.IsNullOrWhiteSpace(_options.ImageGenerationModel) ? "not configured" : _options.ImageGenerationModel;
public bool IsConfigured => MissingConfiguration.Count == 0;
public IReadOnlyList<string> MissingConfiguration
{
get
{
var missing = new List<string>();
if (string.IsNullOrWhiteSpace(_options.ApiKey))
{
missing.Add("OpenAI API key");
}
if (string.IsNullOrWhiteSpace(_options.ImageGenerationModel))
{
missing.Add("image-generation model");
}
return missing;
}
}
public async Task<IllustrationImageGenerationResult> GenerateAsync(IllustrationImageGenerationRequest request, CancellationToken cancellationToken)
{
if (!IsConfigured)
{
return new(false, Provider: ProviderName, Model: EffectiveModel, ErrorMessage: $"OpenAI image generation is not configured. Missing: {string.Join(", ", MissingConfiguration)}.");
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/images/generations");
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
httpRequest.Content = new StringContent(JsonSerializer.Serialize(new
{
model = _options.ImageGenerationModel,
prompt = request.Prompt,
size = string.IsNullOrWhiteSpace(_options.ImageGenerationSize) ? "1024x1024" : _options.ImageGenerationSize,
n = 1
}), Encoding.UTF8, "application/json");
using var response = await httpClient.SendAsync(httpRequest, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = OpenAIImageError.FromResponseBody(body);
logger.LogWarning(
"OpenAI image generation failed for illustration {ItemID} with HTTP {StatusCode}, error code {ErrorCode}: {ErrorMessage}",
request.IllustrationLibraryItemID,
(int)response.StatusCode,
error.Code ?? "none",
error.Message);
return new(false, Provider: ProviderName, Model: EffectiveModel, ErrorMessage: $"Provider returned {(int)response.StatusCode}: {error.ForStorage()}");
}
using var document = JsonDocument.Parse(body);
var requestId = response.Headers.TryGetValues("x-request-id", out var values) ? values.FirstOrDefault() : null;
var b64 = document.RootElement.GetProperty("data")[0].GetProperty("b64_json").GetString();
if (string.IsNullOrWhiteSpace(b64))
{
return new(false, Provider: ProviderName, Model: EffectiveModel, ProviderRequestID: requestId, ErrorMessage: "Provider response did not include image data.");
}
return new(true, Convert.FromBase64String(b64), "image/png", ProviderName, EffectiveModel, requestId);
}
private static string TrimForStorage(string value)
=> value.Length <= 900 ? value : value[..900];
private sealed record OpenAIImageError(string Message, string? Code)
{
public static OpenAIImageError FromResponseBody(string body)
{
try
{
using var document = JsonDocument.Parse(body);
if (document.RootElement.TryGetProperty("error", out var error))
{
var message = error.TryGetProperty("message", out var messageElement) ? messageElement.GetString() : null;
var code = error.TryGetProperty("code", out var codeElement) ? codeElement.GetString() : null;
return new(TrimForStorage(string.IsNullOrWhiteSpace(message) ? body : message), string.IsNullOrWhiteSpace(code) ? null : TrimForStorage(code));
}
}
catch (JsonException)
{
}
return new(TrimForStorage(body), null);
}
public string ForStorage()
=> string.IsNullOrWhiteSpace(Code) ? Message : $"{Code}: {Message}";
}
}
public interface IIllustrationLibraryStorageService
{
IllustrationStoredImage StoreGeneratedImage(int itemId, string category, string code, byte[] imageBytes);
}
public sealed class IllustrationLibraryStorageService(IUploadStorageService uploadStorage) : IIllustrationLibraryStorageService
{
private const int StandardMaxSize = 1200;
private const int ThumbnailSize = 256;
public IllustrationStoredImage StoreGeneratedImage(int itemId, string category, string code, byte[] imageBytes)
{
using var bitmap = SKBitmap.Decode(imageBytes) ?? throw new InvalidDataException("Generated image could not be decoded.");
var safeCode = SafeSegment(code);
var categoryFolder = IllustrationLibraryCategories.StorageFolder(category);
var uploadRoot = uploadStorage.GetDirectory("story-intelligence-library", categoryFolder, safeCode);
var version = $"{itemId}-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}";
var imageFile = $"{version}.webp";
var thumbnailFile = $"{version}-thumb.webp";
var imagePath = Path.Combine(uploadRoot, imageFile);
var thumbnailPath = Path.Combine(uploadRoot, thumbnailFile);
SaveContainedWebp(bitmap, imagePath, StandardMaxSize, 84);
SaveCroppedWebp(bitmap, thumbnailPath, ThumbnailSize, ThumbnailSize, 78);
return new(
uploadStorage.GetPublicPath("story-intelligence-library", categoryFolder, safeCode, imageFile),
uploadStorage.GetPublicPath("story-intelligence-library", categoryFolder, safeCode, thumbnailFile),
bitmap.Width,
bitmap.Height,
new FileInfo(imagePath).Length,
"image/webp");
}
public static string SafeSegment(string value)
{
var builder = new StringBuilder(value.Length);
foreach (var character in value.Trim().ToLowerInvariant())
{
if (char.IsLetterOrDigit(character) || character is '-')
{
builder.Append(character);
}
else if (char.IsWhiteSpace(character) || character is '_' or '.')
{
builder.Append('-');
}
}
var result = builder.ToString().Trim('-');
if (string.IsNullOrWhiteSpace(result) || result.Contains("..", StringComparison.Ordinal))
{
throw new ArgumentException("Illustration storage code is not safe.", nameof(value));
}
return result;
}
private static void SaveContainedWebp(SKBitmap bitmap, string path, int maxSize, int quality)
{
var scale = Math.Min(1f, maxSize / (float)Math.Max(bitmap.Width, bitmap.Height));
var targetWidth = Math.Max(1, (int)Math.Round(bitmap.Width * scale));
var targetHeight = Math.Max(1, (int)Math.Round(bitmap.Height * scale));
using var resized = bitmap.Resize(new SKImageInfo(targetWidth, targetHeight), SKSamplingOptions.Default) ?? bitmap.Copy();
using var image = SKImage.FromBitmap(resized);
using var data = image.Encode(SKEncodedImageFormat.Webp, quality);
using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
data.SaveTo(stream);
}
private static void SaveCroppedWebp(SKBitmap bitmap, string path, int targetWidth, int targetHeight, int quality)
{
var sourceSize = Math.Min(bitmap.Width, bitmap.Height);
var left = (bitmap.Width - sourceSize) / 2;
var top = (bitmap.Height - sourceSize) / 2;
using var surface = SKSurface.Create(new SKImageInfo(targetWidth, targetHeight));
var canvas = surface.Canvas;
canvas.Clear(SKColors.Transparent);
canvas.DrawBitmap(
bitmap,
new SKRect(left, top, left + sourceSize, top + sourceSize),
new SKRect(0, 0, targetWidth, targetHeight));
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Webp, quality);
using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
data.SaveTo(stream);
}
}
public interface IIllustrationGenerationRunner
{
Task ProcessNextAsync(CancellationToken cancellationToken);
}
public sealed class IllustrationGenerationRunner(
IIllustrationLibraryRepository repository,
IIllustrationPromptBuilder promptBuilder,
IIllustrationImageProvider provider,
IIllustrationLibraryStorageService storage,
ILogger<IllustrationGenerationRunner> logger) : IIllustrationGenerationRunner
{
public async Task ProcessNextAsync(CancellationToken cancellationToken)
{
var item = await repository.ClaimNextAsync();
if (item is null)
{
return;
}
try
{
var specification = JsonSerializer.Deserialize<IllustrationGenerationSpecification>(
item.SpecificationJson ?? string.Empty,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (specification is null)
{
await repository.MarkFailedAsync(item.IllustrationLibraryItemID, "Generation specification could not be read.");
return;
}
var prompt = promptBuilder.Build(specification);
var request = new IllustrationImageGenerationRequest(item.IllustrationLibraryItemID, item.StableCode, item.Category, prompt.Prompt, prompt.TemplateVersion);
var result = await provider.GenerateAsync(request, cancellationToken);
if (!result.Succeeded || !result.HasImage)
{
await repository.MarkFailedAsync(item.IllustrationLibraryItemID, result.ErrorMessage ?? "Provider did not return an image.");
return;
}
var stored = storage.StoreGeneratedImage(item.IllustrationLibraryItemID, item.Category, item.StableCode, result.ImageBytes!);
await repository.MarkGeneratedAsync(item.IllustrationLibraryItemID, stored, result, prompt.Prompt, prompt.TemplateVersion);
}
catch (Exception ex) when (ex is JsonException or InvalidDataException or IOException or ArgumentException)
{
logger.LogWarning(ex, "Illustration generation failed for library item {ItemID}.", item.IllustrationLibraryItemID);
await repository.MarkFailedAsync(item.IllustrationLibraryItemID, ex.Message);
}
}
}
public sealed class IllustrationGenerationWorker(
IServiceScopeFactory scopeFactory,
ILogger<IllustrationGenerationWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var runner = scope.ServiceProvider.GetRequiredService<IIllustrationGenerationRunner>();
await runner.ProcessNextAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
logger.LogError(ex, "Illustration generation worker failed while checking the queue.");
}
await timer.WaitForNextTickAsync(stoppingToken);
}
}
}
public interface IIllustrationLibraryService
{
Task<IllustrationLibraryAdminViewModel> BuildAdminViewModelAsync(IllustrationLibraryFilter filter);
Task<IllustrationLibraryActionResult> EnsureStarterPlanAsync();
Task<IllustrationLibraryActionResult> GenerateStarterLibraryAsync(bool confirmed);
Task<IllustrationBulkRetryResult> RequeueAllFailedAsync();
Task<IllustrationLibraryActionResult> QueueAsync(int? illustrationLibraryItemId, string? category, bool confirmed);
Task<IllustrationLibraryActionResult> ApproveAsync(int illustrationLibraryItemId, int userId);
Task<IllustrationLibraryActionResult> RejectAsync(int illustrationLibraryItemId, int userId, string? reason);
Task<IllustrationLibraryActionResult> RegenerateAsync(int illustrationLibraryItemId, bool confirmed);
Task<IllustrationLibraryActionResult> DisableAsync(int illustrationLibraryItemId);
Task<IllustrationLibraryActionResult> UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form);
Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype);
}
public sealed class IllustrationLibraryService(
IIllustrationLibraryRepository repository,
IIllustrationPromptBuilder promptBuilder,
IIllustrationImageProvider provider) : IIllustrationLibraryService
{
private const int MaxGenerationAttempts = 3;
public async Task<IllustrationLibraryAdminViewModel> BuildAdminViewModelAsync(IllustrationLibraryFilter filter)
{
await repository.SupersedeRecoveredFailuresAsync();
var items = await repository.ListAsync(filter);
var allItems = string.IsNullOrWhiteSpace(filter.Category) && string.IsNullOrWhiteSpace(filter.Status) && string.IsNullOrWhiteSpace(filter.Search)
? items
: await repository.ListAsync(new IllustrationLibraryFilter());
var summary = await repository.GetSummaryAsync();
var missingConfiguration = provider.MissingConfiguration;
return new()
{
Filter = filter,
Items = items,
Summary = summary,
StarterCharacterCount = IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Character),
StarterLocationCount = IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Location),
StarterAssetCount = IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Asset),
StarterGenerationPlan = IllustrationStarterBatchDefinition.BuildGenerationPlan(allItems),
ProviderStatus = provider.IsConfigured
? $"Configured: {provider.ProviderName} {provider.EffectiveModel}"
: $"Image generation is not configured. Missing: {string.Join(", ", missingConfiguration)}."
};
}
public async Task<IllustrationLibraryActionResult> EnsureStarterPlanAsync()
{
var createdOrExisting = 0;
foreach (var specification in IllustrationStarterBatchDefinition.All)
{
var prompt = promptBuilder.Build(specification);
await repository.UpsertPlannedAsync(specification, prompt);
createdOrExisting++;
}
return new(true, $"Starter plan is ready with {createdOrExisting:N0} reusable illustration specifications.");
}
public async Task<IllustrationLibraryActionResult> GenerateStarterLibraryAsync(bool confirmed)
{
var beforeItems = await repository.ListAsync(new IllustrationLibraryFilter());
var beforePlan = IllustrationStarterBatchDefinition.BuildGenerationPlan(beforeItems);
if (!confirmed)
{
return new(false, $"Confirm Generate Starter Library first. This will queue {beforePlan.CharactersToGenerate:N0} characters, {beforePlan.LocationsToGenerate:N0} locations and {beforePlan.AssetsToGenerate:N0} assets ({beforePlan.TotalToGenerate:N0} total).");
}
await EnsureStarterPlanAsync();
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var starterItems = items
.Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode))
.Where(item => item.Status is IllustrationLibraryStatuses.Planned or IllustrationLibraryStatuses.Failed)
.Where(item => IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(item.StableCode, items))
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
.Select(group => group.OrderBy(item => item.Status == IllustrationLibraryStatuses.Failed ? 0 : 1).ThenByDescending(item => item.UpdatedUtc).First())
.ToList();
var queued = 0;
foreach (var item in starterItems)
{
queued += await repository.QueueAsync(item.IllustrationLibraryItemID, null);
}
var queuedCharacters = starterItems.Count(item => item.Category == IllustrationLibraryCategories.Character);
var queuedLocations = starterItems.Count(item => item.Category == IllustrationLibraryCategories.Location);
var queuedAssets = starterItems.Count(item => item.Category == IllustrationLibraryCategories.Asset);
return new(
true,
queued == 0
? "Starter Library is already generated, queued or in progress. No new images were queued."
: $"Starter Library generation started: {queuedCharacters:N0} characters, {queuedLocations:N0} locations and {queuedAssets:N0} assets queued ({queued:N0} total).");
}
public async Task<IllustrationBulkRetryResult> RequeueAllFailedAsync()
{
await repository.SupersedeRecoveredFailuresAsync();
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var failed = items
.Where(item => item.Status is IllustrationLibraryStatuses.Failed or IllustrationLibraryStatuses.Superseded)
.ToList();
var retryLimitReached = failed.Count(item => item.GenerationAttemptCount >= MaxGenerationAttempts);
var queued = 0;
var skipped = 0;
foreach (var item in failed.Where(item => item.GenerationAttemptCount < MaxGenerationAttempts))
{
var queuedNow = await QueueRetryTargetAsync(item, items);
queued += queuedNow;
if (queuedNow == 0 && ResolveRetryTarget(item, items) is null)
{
skipped++;
}
}
return new(queued, skipped, retryLimitReached);
}
public static IllustrationLibraryItem? ResolveBulkRetryTarget(IllustrationLibraryItem failedItem, IReadOnlyList<IllustrationLibraryItem> items)
=> ResolveRetryTarget(failedItem, items);
private static IllustrationLibraryItem? ResolveRetryTarget(IllustrationLibraryItem failedItem, IReadOnlyList<IllustrationLibraryItem> items)
{
var child = items
.Where(candidate =>
candidate.ParentItemID == failedItem.IllustrationLibraryItemID
&& candidate.IsActive
&& candidate.Status is IllustrationLibraryStatuses.Planned
or IllustrationLibraryStatuses.Queued
or IllustrationLibraryStatuses.Generating
or IllustrationLibraryStatuses.Generated
or IllustrationLibraryStatuses.Approved)
.OrderByDescending(candidate => candidate.UpdatedUtc)
.ThenByDescending(candidate => candidate.IllustrationLibraryItemID)
.FirstOrDefault();
if (child is not null)
{
return child.Status == IllustrationLibraryStatuses.Planned ? child : null;
}
if (failedItem.Status == IllustrationLibraryStatuses.Superseded)
{
return failedItem;
}
var siblings = items
.Where(candidate =>
candidate.IllustrationLibraryItemID != failedItem.IllustrationLibraryItemID
&& candidate.IsActive
&& string.Equals(candidate.StableCode, failedItem.StableCode, StringComparison.OrdinalIgnoreCase))
.ToList();
if (siblings.Any(candidate => candidate.Status is IllustrationLibraryStatuses.Queued
or IllustrationLibraryStatuses.Generating
or IllustrationLibraryStatuses.Generated
or IllustrationLibraryStatuses.Approved))
{
return null;
}
return siblings
.Where(candidate => candidate.Status == IllustrationLibraryStatuses.Planned)
.OrderByDescending(candidate => candidate.UpdatedUtc)
.ThenByDescending(candidate => candidate.IllustrationLibraryItemID)
.FirstOrDefault()
?? failedItem;
}
private async Task<int> QueueRetryTargetAsync(IllustrationLibraryItem source, IReadOnlyList<IllustrationLibraryItem> items)
{
var target = ResolveRetryTarget(source, items);
if (target is null)
{
return 0;
}
if (target.IllustrationLibraryItemID == source.IllustrationLibraryItemID
&& source.Status == IllustrationLibraryStatuses.Superseded)
{
var regeneration = await repository.CreateRegenerationAsync(source.IllustrationLibraryItemID, "Created from bulk failed-image retry action.");
return regeneration is null ? 0 : await repository.QueueAsync(regeneration.IllustrationLibraryItemID, null);
}
return await repository.QueueAsync(target.IllustrationLibraryItemID, null);
}
public static bool HasActiveBlockingSibling(IllustrationLibraryItem item, IReadOnlyList<IllustrationLibraryItem> items)
=> items.Any(candidate =>
candidate.IllustrationLibraryItemID != item.IllustrationLibraryItemID
&& candidate.IsActive
&& string.Equals(candidate.StableCode, item.StableCode, StringComparison.OrdinalIgnoreCase)
&& candidate.Status is IllustrationLibraryStatuses.Queued
or IllustrationLibraryStatuses.Generating
or IllustrationLibraryStatuses.Generated
or IllustrationLibraryStatuses.Approved);
public async Task<IllustrationLibraryActionResult> QueueAsync(int? illustrationLibraryItemId, string? category, bool confirmed)
{
if (!confirmed)
{
return new(false, "Confirm this queued generation action first. This can create provider cost once image generation is configured.");
}
if (!string.IsNullOrWhiteSpace(category) && !IllustrationLibraryCategories.IsValid(category))
{
return new(false, "Unknown illustration category.");
}
if (illustrationLibraryItemId.HasValue)
{
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var requested = items.FirstOrDefault(item => item.IllustrationLibraryItemID == illustrationLibraryItemId.Value);
if (requested?.Status is IllustrationLibraryStatuses.Failed or IllustrationLibraryStatuses.Superseded)
{
var target = ResolveRetryTarget(requested, items);
if (target is null)
{
return new(true, "This illustration already has a regeneration generated, queued or in progress.");
}
if (target.IllustrationLibraryItemID == requested.IllustrationLibraryItemID
&& requested.Status == IllustrationLibraryStatuses.Superseded)
{
var regeneration = await repository.CreateRegenerationAsync(requested.IllustrationLibraryItemID, "Created from admin queue action.");
if (regeneration is null)
{
return new(false, "This illustration is not eligible for regeneration.");
}
illustrationLibraryItemId = regeneration.IllustrationLibraryItemID;
}
else
{
illustrationLibraryItemId = target.IllustrationLibraryItemID;
}
}
}
var affected = await repository.QueueAsync(illustrationLibraryItemId, category);
return new(true, affected == 0 ? "No eligible planned items were queued." : $"{affected:N0} illustration item(s) queued.");
}
public async Task<IllustrationLibraryActionResult> ApproveAsync(int illustrationLibraryItemId, int userId)
{
await repository.ApproveAsync(illustrationLibraryItemId, userId);
return new(true, "Illustration approved for library use.");
}
public async Task<IllustrationLibraryActionResult> RejectAsync(int illustrationLibraryItemId, int userId, string? reason)
{
await repository.RejectAsync(illustrationLibraryItemId, userId, reason);
return new(true, "Illustration rejected. It can be regenerated later.");
}
public async Task<IllustrationLibraryActionResult> RegenerateAsync(int illustrationLibraryItemId, bool confirmed)
{
if (!confirmed)
{
return new(false, "Confirm regeneration first. This creates a new planned child item and can create provider cost once queued.");
}
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var existing = items
.Where(item =>
item.ParentItemID == illustrationLibraryItemId
&& item.IsActive
&& item.Status is IllustrationLibraryStatuses.Planned
or IllustrationLibraryStatuses.Queued
or IllustrationLibraryStatuses.Generating
or IllustrationLibraryStatuses.Generated
or IllustrationLibraryStatuses.Approved)
.OrderByDescending(item => item.UpdatedUtc)
.ThenByDescending(item => item.IllustrationLibraryItemID)
.FirstOrDefault();
if (existing is not null)
{
if (existing.Status == IllustrationLibraryStatuses.Planned)
{
var queuedExisting = await repository.QueueAsync(existing.IllustrationLibraryItemID, null);
return queuedExisting == 0
? new(true, "This regeneration is already queued or in progress.")
: new(true, $"Existing regeneration item {existing.IllustrationLibraryItemID:N0} queued.");
}
return new(true, "This illustration already has a regeneration generated, queued or in progress.");
}
var item = await repository.CreateRegenerationAsync(illustrationLibraryItemId, "Created from admin regeneration action.");
if (item is null)
{
return new(false, "This illustration is not eligible for regeneration.");
}
var queued = await repository.QueueAsync(item.IllustrationLibraryItemID, null);
return queued == 0
? new(false, "Regeneration was created but could not be queued.")
: new(true, $"Regeneration item {item.IllustrationLibraryItemID:N0} created and queued.");
}
public async Task<IllustrationLibraryActionResult> DisableAsync(int illustrationLibraryItemId)
{
await repository.DisableAsync(illustrationLibraryItemId);
return new(true, "Illustration disabled.");
}
public async Task<IllustrationLibraryActionResult> UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form)
{
if (string.IsNullOrWhiteSpace(form.DisplayTitle))
{
return new(false, "Title is required.");
}
await repository.UpdateMetadataAsync(illustrationLibraryItemId, form.DisplayTitle, form.ShortDescription, form.MetadataJson, form.AdminNotes);
return new(true, "Illustration metadata updated.");
}
public async Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype)
{
var items = await repository.ListAsync(new IllustrationLibraryFilter());
var lookup = items
.Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode))
.Where(item => item.FilePath is not null && item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1)
.ThenByDescending(item => item.UpdatedUtc)
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().FilePath!, StringComparer.OrdinalIgnoreCase);
foreach (var scene in prototype.Scenes)
{
if (!string.IsNullOrWhiteSpace(scene.Location.LibraryCode) && lookup.TryGetValue(scene.Location.LibraryCode, out var locationPath))
{
scene.Location.ImagePath = locationPath;
}
foreach (var character in scene.Characters)
{
if (!string.IsNullOrWhiteSpace(character.LibraryCode) && lookup.TryGetValue(character.LibraryCode, out var characterPath))
{
character.ImagePath = characterPath;
}
}
foreach (var asset in scene.Assets)
{
if (!string.IsNullOrWhiteSpace(asset.LibraryCode) && lookup.TryGetValue(asset.LibraryCode, out var assetPath))
{
asset.ImagePath = assetPath;
}
}
}
}
}

View File

@ -174,13 +174,13 @@ public static class StoryIntelligenceExperiencePrototypeData
};
private static StoryIntelligenceExperienceLocationState Location(string id, string name, string label, string image)
=> new() { Id = id, Name = name, Label = label, ImagePath = $"{AssetRoot}/{image}" };
=> new() { Id = id, LibraryCode = LocationLibraryCode(id), Name = name, Label = label, ImagePath = $"{AssetRoot}/{image}" };
private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> Characters(params (string Id, string Name, string Role, string Relevance, int Weight, string Image)[] items)
=> items.Select(item => new StoryIntelligenceExperienceCharacterState { Id = item.Id, Name = item.Name, Role = item.Role, Relevance = item.Relevance, Weight = item.Weight, ImagePath = $"{AssetRoot}/{item.Image}" }).ToList();
=> items.Select(item => new StoryIntelligenceExperienceCharacterState { Id = item.Id, LibraryCode = CharacterLibraryCode(item.Id), Name = item.Name, Role = item.Role, Relevance = item.Relevance, Weight = item.Weight, ImagePath = $"{AssetRoot}/{item.Image}" }).ToList();
private static IReadOnlyList<StoryIntelligenceExperienceAssetState> Assets(params (string Id, string Name, string Label, int Weight, string Image)[] items)
=> items.Select(item => new StoryIntelligenceExperienceAssetState { Id = item.Id, Name = item.Name, Label = item.Label, Weight = item.Weight, ImagePath = $"{AssetRoot}/{item.Image}" }).ToList();
=> items.Select(item => new StoryIntelligenceExperienceAssetState { Id = item.Id, LibraryCode = AssetLibraryCode(item.Id), Name = item.Name, Label = item.Label, Weight = item.Weight, ImagePath = $"{AssetRoot}/{item.Image}" }).ToList();
private static IReadOnlyList<StoryIntelligenceExperienceRelationshipState> Relationships(params (string Id, string Source, string Target, string Label, string State, int Weight)[] items)
=> items.Select(item => new StoryIntelligenceExperienceRelationshipState { Id = item.Id, SourceId = item.Source, TargetId = item.Target, Label = item.Label, State = item.State, Weight = item.Weight }).ToList();
@ -197,4 +197,37 @@ public static class StoryIntelligenceExperiencePrototypeData
private static IReadOnlyList<string> Discoveries(params string[] items) => items;
private static IReadOnlyList<string> Feed(params string[] items) => items;
private static string CharacterLibraryCode(string id)
=> id switch
{
"beth" => "char-young-adult-brunette-observer",
"rosie" => "char-young-adult-red-haired-witness",
"graham" => "char-middle-aged-weathered-man",
"maggie" => "char-older-soft-presence",
_ => "char-unknown-figure"
};
private static string LocationLibraryCode(string id)
=> id switch
{
"doweries" => "loc-estate-house-exterior",
"maggie-flat" => "loc-small-flat-interior",
"laundry" => "loc-laundry-utility-room",
"stairwell" => "loc-narrow-stairwell",
"car-park" => "loc-wet-car-park",
_ => "loc-domestic-desk"
};
private static string AssetLibraryCode(string id)
=> id switch
{
"letter" => "asset-folded-letter",
"notebook" => "asset-worn-notebook",
"red-tr6" => "asset-red-classic-car",
"keys" => "asset-old-keys",
"rucksack" => "asset-rucksack",
"phone" => "asset-phone-recorder",
_ => "asset-locked-box"
};
}

View File

@ -0,0 +1,383 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.IllustrationLibraryItems', N'U') IS NULL
BEGIN
CREATE TABLE dbo.IllustrationLibraryItems
(
IllustrationLibraryItemID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_IllustrationLibraryItems PRIMARY KEY,
Category nvarchar(40) NOT NULL,
StableCode nvarchar(120) NOT NULL,
DisplayTitle nvarchar(200) NOT NULL,
ShortDescription nvarchar(500) NULL,
MetadataJson nvarchar(max) NULL,
SpecificationJson nvarchar(max) NULL,
FilePath nvarchar(500) NULL,
ThumbnailPath nvarchar(500) NULL,
Width int NULL,
Height int NULL,
FileSizeBytes bigint NULL,
MimeType nvarchar(100) NULL,
Status nvarchar(40) NOT NULL CONSTRAINT DF_IllustrationLibraryItems_Status DEFAULT N'Planned',
IsActive bit NOT NULL CONSTRAINT DF_IllustrationLibraryItems_IsActive DEFAULT 1,
IsApproved bit NOT NULL CONSTRAINT DF_IllustrationLibraryItems_IsApproved DEFAULT 0,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_IllustrationLibraryItems_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_IllustrationLibraryItems_UpdatedUtc DEFAULT SYSUTCDATETIME(),
ApprovedUtc datetime2(0) NULL,
ApprovedByUserID int NULL,
Provider nvarchar(80) NULL,
Model nvarchar(120) NULL,
PromptText nvarchar(max) NULL,
PromptTemplateVersion nvarchar(40) NULL,
ProviderRequestID nvarchar(160) NULL,
AttemptCount int NOT NULL CONSTRAINT DF_IllustrationLibraryItems_AttemptCount DEFAULT 0,
ParentItemID int NULL,
RejectionReason nvarchar(500) NULL,
AdminNotes nvarchar(1000) NULL,
UsageCount int NOT NULL CONSTRAINT DF_IllustrationLibraryItems_UsageCount DEFAULT 0,
LastError nvarchar(1000) NULL,
QueuedUtc datetime2(0) NULL,
StartedUtc datetime2(0) NULL,
CompletedUtc datetime2(0) NULL,
CONSTRAINT CK_IllustrationLibraryItems_Category CHECK (Category IN (N'Character', N'Location', N'Asset')),
CONSTRAINT CK_IllustrationLibraryItems_Status CHECK (Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved', N'Rejected', N'Failed', N'Disabled'))
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_IllustrationLibraryItems_Parent')
BEGIN
ALTER TABLE dbo.IllustrationLibraryItems
ADD CONSTRAINT FK_IllustrationLibraryItems_Parent
FOREIGN KEY (ParentItemID) REFERENCES dbo.IllustrationLibraryItems(IllustrationLibraryItemID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_IllustrationLibraryItems_ApprovedBy')
AND OBJECT_ID(N'dbo.AppUser', N'U') IS NOT NULL
BEGIN
ALTER TABLE dbo.IllustrationLibraryItems
ADD CONSTRAINT FK_IllustrationLibraryItems_ApprovedBy
FOREIGN KEY (ApprovedByUserID) REFERENCES dbo.AppUser(UserID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_IllustrationLibraryItems_Status' AND object_id = OBJECT_ID(N'dbo.IllustrationLibraryItems'))
CREATE INDEX IX_IllustrationLibraryItems_Status ON dbo.IllustrationLibraryItems(Status, Category, UpdatedUtc DESC);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_IllustrationLibraryItems_Code' AND object_id = OBJECT_ID(N'dbo.IllustrationLibraryItems'))
CREATE INDEX IX_IllustrationLibraryItems_Code ON dbo.IllustrationLibraryItems(StableCode, Status, IsActive);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_IllustrationLibraryItems_ActivePlannedCode' AND object_id = OBJECT_ID(N'dbo.IllustrationLibraryItems'))
CREATE UNIQUE INDEX UX_IllustrationLibraryItems_ActivePlannedCode
ON dbo.IllustrationLibraryItems(StableCode)
WHERE ParentItemID IS NULL AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved') AND IsActive = 1;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_List
@Category nvarchar(40) = NULL,
@Status nvarchar(40) = NULL,
@Search nvarchar(120) = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT *
FROM dbo.IllustrationLibraryItems
WHERE (@Category IS NULL OR Category = @Category)
AND (@Status IS NULL OR Status = @Status)
AND (
@Search IS NULL
OR StableCode LIKE N'%' + @Search + N'%'
OR DisplayTitle LIKE N'%' + @Search + N'%'
OR ShortDescription LIKE N'%' + @Search + N'%'
)
ORDER BY
CASE Status
WHEN N'Generating' THEN 1
WHEN N'Queued' THEN 2
WHEN N'Generated' THEN 3
WHEN N'Planned' THEN 4
WHEN N'Failed' THEN 5
WHEN N'Rejected' THEN 6
WHEN N'Approved' THEN 7
ELSE 8
END,
UpdatedUtc DESC,
IllustrationLibraryItemID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Summary
AS
BEGIN
SET NOCOUNT ON;
SELECT
COUNT(1) AS TotalCount,
ISNULL(SUM(CASE WHEN Status = N'Planned' THEN 1 ELSE 0 END), 0) AS PlannedCount,
ISNULL(SUM(CASE WHEN Status = N'Queued' THEN 1 ELSE 0 END), 0) AS QueuedCount,
ISNULL(SUM(CASE WHEN Status = N'Generating' THEN 1 ELSE 0 END), 0) AS GeneratingCount,
ISNULL(SUM(CASE WHEN Status = N'Generated' THEN 1 ELSE 0 END), 0) AS GeneratedCount,
ISNULL(SUM(CASE WHEN Status = N'Approved' THEN 1 ELSE 0 END), 0) AS ApprovedCount,
ISNULL(SUM(CASE WHEN Status = N'Rejected' THEN 1 ELSE 0 END), 0) AS RejectedCount,
ISNULL(SUM(CASE WHEN Status = N'Failed' THEN 1 ELSE 0 END), 0) AS FailedCount,
ISNULL(SUM(CASE WHEN Status = N'Disabled' THEN 1 ELSE 0 END), 0) AS DisabledCount
FROM dbo.IllustrationLibraryItems;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Get
@IllustrationLibraryItemID int
AS
BEGIN
SET NOCOUNT ON;
SELECT * FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_UpsertPlanned
@Category nvarchar(40),
@StableCode nvarchar(120),
@DisplayTitle nvarchar(200),
@ShortDescription nvarchar(500) = NULL,
@MetadataJson nvarchar(max) = NULL,
@SpecificationJson nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (
SELECT 1
FROM dbo.IllustrationLibraryItems
WHERE StableCode = @StableCode
AND ParentItemID IS NULL
AND IsActive = 1
AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved')
)
BEGIN
SELECT * FROM dbo.IllustrationLibraryItems
WHERE StableCode = @StableCode
AND ParentItemID IS NULL
AND IsActive = 1
AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved');
RETURN;
END;
INSERT dbo.IllustrationLibraryItems
(Category, StableCode, DisplayTitle, ShortDescription, MetadataJson, SpecificationJson, Status)
VALUES
(@Category, @StableCode, @DisplayTitle, @ShortDescription, @MetadataJson, @SpecificationJson, N'Planned');
SELECT * FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = SCOPE_IDENTITY();
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Queue
@IllustrationLibraryItemID int = NULL,
@Category nvarchar(40) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Queued',
QueuedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME(),
LastError = NULL
WHERE (@IllustrationLibraryItemID IS NULL OR IllustrationLibraryItemID = @IllustrationLibraryItemID)
AND (@Category IS NULL OR Category = @Category)
AND Status IN (N'Planned', N'Failed', N'Rejected')
AND IsActive = 1;
SELECT @@ROWCOUNT AS AffectedRows;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_ClaimNext
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @Claimed TABLE (IllustrationLibraryItemID int NOT NULL);
;WITH nextItem AS
(
SELECT TOP (1) *
FROM dbo.IllustrationLibraryItems WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE Status = N'Queued' AND IsActive = 1
ORDER BY QueuedUtc, IllustrationLibraryItemID
)
UPDATE nextItem
SET Status = N'Generating',
AttemptCount = AttemptCount + 1,
StartedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
OUTPUT inserted.IllustrationLibraryItemID INTO @Claimed;
SELECT item.*
FROM dbo.IllustrationLibraryItems item
INNER JOIN @Claimed claimed ON claimed.IllustrationLibraryItemID = item.IllustrationLibraryItemID;
COMMIT TRANSACTION;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_MarkGenerated
@IllustrationLibraryItemID int,
@FilePath nvarchar(500),
@ThumbnailPath nvarchar(500),
@Width int,
@Height int,
@FileSizeBytes bigint,
@MimeType nvarchar(100),
@Provider nvarchar(80),
@Model nvarchar(120),
@PromptText nvarchar(max),
@PromptTemplateVersion nvarchar(40),
@ProviderRequestID nvarchar(160) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Generated',
FilePath = @FilePath,
ThumbnailPath = @ThumbnailPath,
Width = @Width,
Height = @Height,
FileSizeBytes = @FileSizeBytes,
MimeType = @MimeType,
Provider = @Provider,
Model = @Model,
PromptText = @PromptText,
PromptTemplateVersion = @PromptTemplateVersion,
ProviderRequestID = @ProviderRequestID,
CompletedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME(),
LastError = NULL
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generating';
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_MarkFailed
@IllustrationLibraryItemID int,
@LastError nvarchar(1000)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Failed',
LastError = @LastError,
CompletedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generating';
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Approve
@IllustrationLibraryItemID int,
@ApprovedByUserID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @StableCode nvarchar(120);
SELECT @StableCode = StableCode FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID;
UPDATE dbo.IllustrationLibraryItems
SET IsApproved = 0,
Status = CASE WHEN Status = N'Approved' THEN N'Generated' ELSE Status END,
UpdatedUtc = SYSUTCDATETIME()
WHERE StableCode = @StableCode
AND IllustrationLibraryItemID <> @IllustrationLibraryItemID
AND Status = N'Approved';
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Approved',
IsApproved = 1,
ApprovedUtc = SYSUTCDATETIME(),
ApprovedByUserID = @ApprovedByUserID,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generated'
AND FilePath IS NOT NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Reject
@IllustrationLibraryItemID int,
@RejectionReason nvarchar(500) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Rejected',
IsApproved = 0,
RejectionReason = @RejectionReason,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status IN (N'Generated', N'Approved');
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Disable
@IllustrationLibraryItemID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Disabled',
IsActive = 0,
IsApproved = 0,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_UpdateMetadata
@IllustrationLibraryItemID int,
@DisplayTitle nvarchar(200),
@ShortDescription nvarchar(500) = NULL,
@MetadataJson nvarchar(max) = NULL,
@AdminNotes nvarchar(1000) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET DisplayTitle = @DisplayTitle,
ShortDescription = @ShortDescription,
MetadataJson = @MetadataJson,
AdminNotes = @AdminNotes,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_GetApproved
AS
BEGIN
SET NOCOUNT ON;
SELECT *
FROM dbo.IllustrationLibraryItems
WHERE Status = N'Approved'
AND IsApproved = 1
AND IsActive = 1
AND FilePath IS NOT NULL
ORDER BY StableCode, ApprovedUtc DESC;
END;
GO

View File

@ -0,0 +1,317 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'DisplayName') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD DisplayName nvarchar(200) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'Description') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD Description nvarchar(500) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'Prompt') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD Prompt nvarchar(max) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'GenerationAttemptCount') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD GenerationAttemptCount int NOT NULL CONSTRAINT DF_IllustrationLibraryItems_GenerationAttemptCount DEFAULT 0;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'ErrorMessage') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD ErrorMessage nvarchar(1000) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'RejectedUtc') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD RejectedUtc datetime2(0) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'RejectedByUserID') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD RejectedByUserID int NULL;
GO
UPDATE dbo.IllustrationLibraryItems
SET DisplayName = ISNULL(DisplayName, DisplayTitle),
Description = ISNULL(Description, ShortDescription),
Prompt = ISNULL(Prompt, PromptText),
GenerationAttemptCount = CASE WHEN GenerationAttemptCount = 0 THEN AttemptCount ELSE GenerationAttemptCount END,
ErrorMessage = ISNULL(ErrorMessage, LastError)
WHERE DisplayName IS NULL
OR Description IS NULL
OR Prompt IS NULL
OR ErrorMessage IS NULL
OR GenerationAttemptCount = 0;
GO
IF EXISTS (SELECT 1 FROM sys.check_constraints WHERE name = N'CK_IllustrationLibraryItems_Status')
ALTER TABLE dbo.IllustrationLibraryItems DROP CONSTRAINT CK_IllustrationLibraryItems_Status;
GO
ALTER TABLE dbo.IllustrationLibraryItems
ADD CONSTRAINT CK_IllustrationLibraryItems_Status
CHECK (Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved', N'Rejected', N'Failed', N'Superseded', N'Disabled'));
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_IllustrationLibraryItems_RejectedBy')
AND OBJECT_ID(N'dbo.AppUser', N'U') IS NOT NULL
BEGIN
ALTER TABLE dbo.IllustrationLibraryItems
ADD CONSTRAINT FK_IllustrationLibraryItems_RejectedBy
FOREIGN KEY (RejectedByUserID) REFERENCES dbo.AppUser(UserID);
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_List
@Category nvarchar(40) = NULL,
@Status nvarchar(40) = NULL,
@Search nvarchar(120) = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT *
FROM dbo.IllustrationLibraryItems
WHERE (@Category IS NULL OR Category = @Category)
AND (@Status IS NULL OR Status = @Status)
AND (
@Search IS NULL
OR StableCode LIKE N'%' + @Search + N'%'
OR ISNULL(DisplayName, DisplayTitle) LIKE N'%' + @Search + N'%'
OR ISNULL(Description, ShortDescription) LIKE N'%' + @Search + N'%'
)
ORDER BY
CASE Status
WHEN N'Generating' THEN 1
WHEN N'Queued' THEN 2
WHEN N'Generated' THEN 3
WHEN N'Planned' THEN 4
WHEN N'Failed' THEN 5
WHEN N'Rejected' THEN 6
WHEN N'Approved' THEN 7
WHEN N'Superseded' THEN 8
ELSE 9
END,
UpdatedUtc DESC,
IllustrationLibraryItemID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Summary
AS
BEGIN
SET NOCOUNT ON;
SELECT
COUNT(1) AS TotalCount,
ISNULL(SUM(CASE WHEN Status = N'Planned' THEN 1 ELSE 0 END), 0) AS PlannedCount,
ISNULL(SUM(CASE WHEN Status = N'Queued' THEN 1 ELSE 0 END), 0) AS QueuedCount,
ISNULL(SUM(CASE WHEN Status = N'Generating' THEN 1 ELSE 0 END), 0) AS GeneratingCount,
ISNULL(SUM(CASE WHEN Status = N'Generated' THEN 1 ELSE 0 END), 0) AS GeneratedCount,
ISNULL(SUM(CASE WHEN Status = N'Approved' THEN 1 ELSE 0 END), 0) AS ApprovedCount,
ISNULL(SUM(CASE WHEN Status = N'Rejected' THEN 1 ELSE 0 END), 0) AS RejectedCount,
ISNULL(SUM(CASE WHEN Status = N'Failed' THEN 1 ELSE 0 END), 0) AS FailedCount,
ISNULL(SUM(CASE WHEN Status = N'Superseded' THEN 1 ELSE 0 END), 0) AS SupersededCount,
ISNULL(SUM(CASE WHEN Status = N'Disabled' THEN 1 ELSE 0 END), 0) AS DisabledCount
FROM dbo.IllustrationLibraryItems;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_UpsertPlanned
@Category nvarchar(40),
@StableCode nvarchar(120),
@DisplayTitle nvarchar(200),
@ShortDescription nvarchar(500) = NULL,
@MetadataJson nvarchar(max) = NULL,
@SpecificationJson nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (
SELECT 1
FROM dbo.IllustrationLibraryItems
WHERE StableCode = @StableCode
AND ParentItemID IS NULL
AND IsActive = 1
AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved')
)
BEGIN
SELECT * FROM dbo.IllustrationLibraryItems
WHERE StableCode = @StableCode
AND ParentItemID IS NULL
AND IsActive = 1
AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved');
RETURN;
END;
INSERT dbo.IllustrationLibraryItems
(Category, StableCode, DisplayTitle, DisplayName, ShortDescription, Description, MetadataJson, SpecificationJson, Status)
VALUES
(@Category, @StableCode, @DisplayTitle, @DisplayTitle, @ShortDescription, @ShortDescription, @MetadataJson, @SpecificationJson, N'Planned');
SELECT * FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = SCOPE_IDENTITY();
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_ClaimNext
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @Claimed TABLE (IllustrationLibraryItemID int NOT NULL);
;WITH nextItem AS
(
SELECT TOP (1) *
FROM dbo.IllustrationLibraryItems WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE Status = N'Queued'
AND IsActive = 1
AND GenerationAttemptCount < 3
ORDER BY QueuedUtc, IllustrationLibraryItemID
)
UPDATE nextItem
SET Status = N'Generating',
AttemptCount = AttemptCount + 1,
GenerationAttemptCount = GenerationAttemptCount + 1,
StartedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
OUTPUT inserted.IllustrationLibraryItemID INTO @Claimed;
SELECT item.*
FROM dbo.IllustrationLibraryItems item
INNER JOIN @Claimed claimed ON claimed.IllustrationLibraryItemID = item.IllustrationLibraryItemID;
COMMIT TRANSACTION;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_MarkGenerated
@IllustrationLibraryItemID int,
@FilePath nvarchar(500),
@ThumbnailPath nvarchar(500),
@Width int,
@Height int,
@FileSizeBytes bigint,
@MimeType nvarchar(100),
@Provider nvarchar(80),
@Model nvarchar(120),
@PromptText nvarchar(max),
@PromptTemplateVersion nvarchar(40),
@ProviderRequestID nvarchar(160) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Generated',
FilePath = @FilePath,
ThumbnailPath = @ThumbnailPath,
Width = @Width,
Height = @Height,
FileSizeBytes = @FileSizeBytes,
MimeType = @MimeType,
Provider = @Provider,
Model = @Model,
PromptText = @PromptText,
Prompt = @PromptText,
PromptTemplateVersion = @PromptTemplateVersion,
ProviderRequestID = @ProviderRequestID,
CompletedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME(),
LastError = NULL,
ErrorMessage = NULL
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generating';
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_MarkFailed
@IllustrationLibraryItemID int,
@LastError nvarchar(1000)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Failed',
LastError = @LastError,
ErrorMessage = @LastError,
CompletedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generating';
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Approve
@IllustrationLibraryItemID int,
@ApprovedByUserID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @StableCode nvarchar(120);
SELECT @StableCode = StableCode FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID;
UPDATE dbo.IllustrationLibraryItems
SET IsApproved = 0,
Status = N'Superseded',
UpdatedUtc = SYSUTCDATETIME()
WHERE StableCode = @StableCode
AND IllustrationLibraryItemID <> @IllustrationLibraryItemID
AND Status = N'Approved';
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Approved',
IsApproved = 1,
ApprovedUtc = SYSUTCDATETIME(),
ApprovedByUserID = @ApprovedByUserID,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generated'
AND FilePath IS NOT NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Reject
@IllustrationLibraryItemID int,
@RejectedByUserID int,
@RejectionReason nvarchar(500) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Rejected',
IsApproved = 0,
RejectedUtc = SYSUTCDATETIME(),
RejectedByUserID = @RejectedByUserID,
RejectionReason = @RejectionReason,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status IN (N'Generated', N'Approved');
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_CreateRegeneration
@IllustrationLibraryItemID int,
@AdminNotes nvarchar(1000) = NULL
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.IllustrationLibraryItems
(Category, StableCode, DisplayTitle, DisplayName, ShortDescription, Description, MetadataJson, SpecificationJson, Status, ParentItemID, AdminNotes)
SELECT Category, StableCode, DisplayTitle, ISNULL(DisplayName, DisplayTitle), ShortDescription, ISNULL(Description, ShortDescription),
MetadataJson, SpecificationJson, N'Planned', IllustrationLibraryItemID, @AdminNotes
FROM dbo.IllustrationLibraryItems
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status IN (N'Generated', N'Approved', N'Rejected', N'Failed', N'Superseded');
SELECT * FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = SCOPE_IDENTITY();
END;
GO

View File

@ -0,0 +1,69 @@
using PlotLine.Models;
namespace PlotLine.ViewModels;
public sealed class IllustrationLibraryAdminViewModel
{
public IllustrationLibraryFilter Filter { get; init; } = new();
public IllustrationLibrarySummary Summary { get; init; } = new();
public IReadOnlyList<IllustrationLibraryItem> Items { get; init; } = [];
public IReadOnlyList<string> Categories { get; init; } = IllustrationLibraryCategories.All;
public IReadOnlyList<string> Statuses { get; init; } =
[
IllustrationLibraryStatuses.Planned,
IllustrationLibraryStatuses.Queued,
IllustrationLibraryStatuses.Generating,
IllustrationLibraryStatuses.Generated,
IllustrationLibraryStatuses.Approved,
IllustrationLibraryStatuses.Rejected,
IllustrationLibraryStatuses.Failed,
IllustrationLibraryStatuses.Superseded,
IllustrationLibraryStatuses.Disabled
];
public int StarterCharacterCount { get; init; }
public int StarterLocationCount { get; init; }
public int StarterAssetCount { get; init; }
public IllustrationStarterGenerationPlan StarterGenerationPlan { get; init; } = new(0, 0, 0);
public string StoragePathHint { get; init; } = "/uploads/story-intelligence-library";
public string ProviderStatus { get; init; } = string.Empty;
public int CompletedTotal => Summary.GeneratedCount + Summary.ApprovedCount + Summary.FailedCount + Summary.SupersededCount;
}
public sealed class IllustrationLibraryQueueForm
{
public bool Confirmed { get; set; }
public string? Category { get; set; }
public string? Status { get; set; }
public string? Search { get; set; }
}
public sealed class IllustrationLibraryBulkRetryForm
{
public string? Category { get; set; }
public string? Status { get; set; }
public string? Search { get; set; }
}
public sealed class IllustrationStarterGenerationForm
{
public bool Confirmed { get; set; }
}
public sealed class IllustrationLibraryRejectForm
{
public string? Reason { get; set; }
public string? Category { get; set; }
public string? Status { get; set; }
public string? Search { get; set; }
}
public sealed class IllustrationLibraryMetadataForm
{
public string? DisplayTitle { get; set; }
public string? ShortDescription { get; set; }
public string? MetadataJson { get; set; }
public string? AdminNotes { get; set; }
public string? Category { get; set; }
public string? Status { get; set; }
public string? Search { get; set; }
}

View File

@ -36,28 +36,31 @@ public sealed class StoryIntelligenceExperienceSceneState
public sealed class StoryIntelligenceExperienceCharacterState
{
public string Id { get; init; } = string.Empty;
public string LibraryCode { get; set; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Role { get; init; } = string.Empty;
public string Relevance { get; init; } = string.Empty;
public int Weight { get; init; }
public string ImagePath { get; init; } = string.Empty;
public string ImagePath { get; set; } = string.Empty;
}
public sealed class StoryIntelligenceExperienceLocationState
{
public string Id { get; init; } = string.Empty;
public string LibraryCode { get; set; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty;
public string ImagePath { get; init; } = string.Empty;
public string ImagePath { get; set; } = string.Empty;
}
public sealed class StoryIntelligenceExperienceAssetState
{
public string Id { get; init; } = string.Empty;
public string LibraryCode { get; set; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty;
public int Weight { get; init; }
public string ImagePath { get; init; } = string.Empty;
public string ImagePath { get; set; } = string.Empty;
}
public sealed class StoryIntelligenceExperienceRelationshipState

View File

@ -88,6 +88,7 @@
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceFullChapterTest">Full chapter test</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceExistingChapter">Queue chapter</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceRuns">Saved runs</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceIllustrationLibrary">Illustration library</a>
</div>
</article>
<article class="project-card">

View File

@ -0,0 +1,241 @@
@model IllustrationLibraryAdminViewModel
@{
ViewData["Title"] = "Illustration Library";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin / Story Intelligence</p>
<h1>Illustration Library</h1>
<p class="lead-text">Representative visual identifiers for the Story Intelligence experience. These are reusable library illustrations, not canonical manuscript artwork.</p>
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<span class="muted"> / </span>
<a asp-action="Index">Admin</a>
<span class="muted"> / Illustration Library</span>
</nav>
@if (TempData["AdminMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
@if (TempData["AdminError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
<section class="edit-panel">
<div class="d-flex flex-wrap gap-3 align-items-center justify-content-between">
<div>
<p class="eyebrow">Generation workflow</p>
<h2 class="mb-1">Planned → queued → generating → generated</h2>
<p class="muted mb-0">@Model.ProviderStatus</p>
<p class="muted mb-0">Storage: @Model.StoragePathHint</p>
</div>
<div class="d-flex flex-wrap gap-2">
<form asp-action="GenerateIllustrationStarterLibrary" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Confirmed" value="true" />
<button type="submit" class="btn btn-primary">Generate Starter Library</button>
</form>
<form asp-action="RequeueFailedIllustrationLibraryItems" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-outline-primary">Requeue All Failed</button>
</form>
</div>
</div>
</section>
<section class="edit-panel">
<div class="row g-3">
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Total</p><h3>@Model.Summary.TotalCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Planned</p><h3>@Model.Summary.PlannedCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Queued</p><h3>@Model.Summary.QueuedCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Generated</p><h3>@Model.Summary.GeneratedCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Approved</p><h3>@Model.Summary.ApprovedCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Failed</p><h3>@Model.Summary.FailedCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Superseded</p><h3>@Model.Summary.SupersededCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Completed</p><h3>@Model.CompletedTotal.ToString("N0")</h3></div>
</div>
</div>
<p class="muted mt-3 mb-0">Starter library: @Model.StarterCharacterCount characters, @Model.StarterLocationCount locations, @Model.StarterAssetCount assets. Next generation click will queue @Model.StarterGenerationPlan.TotalToGenerate total.</p>
</section>
<section class="edit-panel">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label" for="category">Category</label>
<select class="form-select" id="category" name="Category">
<option value="">All</option>
@foreach (var category in Model.Categories)
{
<option value="@category" selected="@(string.Equals(Model.Filter.Category, category, StringComparison.OrdinalIgnoreCase))">@category</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="status">Status</label>
<select class="form-select" id="status" name="Status">
<option value="">All</option>
@foreach (var status in Model.Statuses)
{
<option value="@status" selected="@(string.Equals(Model.Filter.Status, status, StringComparison.OrdinalIgnoreCase))">@status</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="search">Search</label>
<input class="form-control" id="search" name="Search" value="@Model.Filter.Search" />
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-outline-primary w-100">Filter</button>
</div>
</form>
</section>
<section class="project-card-grid">
@foreach (var item in Model.Items)
{
var preview = !string.IsNullOrWhiteSpace(item.ThumbnailPath) ? item.ThumbnailPath : item.FilePath;
<article class="project-card">
<div class="project-card__body">
<div class="d-flex justify-content-between gap-2 align-items-start">
<div>
<p class="eyebrow">@item.Category / @item.Status</p>
<h2>@item.DisplayTitle</h2>
</div>
<span class="badge bg-secondary">@item.AttemptCount.ToString("N0") tries</span>
</div>
@if (!string.IsNullOrWhiteSpace(preview))
{
<img src="@preview" alt="@item.DisplayTitle" class="img-fluid rounded border mb-3" loading="lazy" />
}
<p class="project-card__description">@item.ShortDescription</p>
<dl class="small mb-3">
<dt>Code</dt>
<dd><code>@item.StableCode</code></dd>
<dt>Provider</dt>
<dd>@(item.Provider ?? "Not generated") @(item.Model is null ? string.Empty : $" / {item.Model}")</dd>
<dt>Updated</dt>
<dd>@item.UpdatedUtc.ToString("yyyy-MM-dd HH:mm") UTC</dd>
@if (item.ApprovedUtc.HasValue)
{
<dt>Approved</dt>
<dd>@item.ApprovedUtc.Value.ToString("yyyy-MM-dd HH:mm") UTC</dd>
}
@if (item.RejectedUtc.HasValue)
{
<dt>Rejected</dt>
<dd>@item.RejectedUtc.Value.ToString("yyyy-MM-dd HH:mm") UTC</dd>
}
@if (!string.IsNullOrWhiteSpace(item.LastError))
{
<dt>Failure</dt>
<dd>@(item.ErrorMessage ?? item.LastError)</dd>
}
</dl>
<details class="mb-3">
<summary>Prompt and metadata</summary>
<label class="form-label mt-2">Prompt</label>
<textarea class="form-control" rows="6" readonly>@(item.Prompt ?? item.PromptText)</textarea>
<label class="form-label mt-2">Specification</label>
<textarea class="form-control" rows="6" readonly>@item.SpecificationJson</textarea>
<label class="form-label mt-2">Metadata</label>
<textarea class="form-control" rows="5" readonly>@item.MetadataJson</textarea>
</details>
<form asp-action="UpdateIllustrationLibraryMetadata" asp-route-id="@item.IllustrationLibraryItemID" method="post" class="mb-2">
@Html.AntiForgeryToken()
<input type="hidden" name="DisplayTitle" value="@item.DisplayTitle" />
<input type="hidden" name="ShortDescription" value="@item.ShortDescription" />
<input type="hidden" name="MetadataJson" value="@item.MetadataJson" />
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<label class="form-label">Admin notes</label>
<textarea class="form-control mb-2" name="AdminNotes" rows="2">@item.AdminNotes</textarea>
<button type="submit" class="btn btn-outline-primary btn-sm">Save notes</button>
</form>
</div>
<div class="project-card__actions">
@if (item.Status is IllustrationLibraryStatuses.Planned or IllustrationLibraryStatuses.Failed or IllustrationLibraryStatuses.Rejected)
{
<form asp-action="QueueIllustrationLibraryItem" asp-route-id="@item.IllustrationLibraryItemID" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Confirmed" value="true" />
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-primary btn-sm">Queue</button>
</form>
}
@if (item.Status == IllustrationLibraryStatuses.Generated)
{
<form asp-action="ApproveIllustrationLibraryItem" asp-route-id="@item.IllustrationLibraryItemID" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-success btn-sm">Approve</button>
</form>
<form asp-action="RejectIllustrationLibraryItem" asp-route-id="@item.IllustrationLibraryItemID" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Reason" value="Rejected from Studio review" />
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-outline-danger btn-sm">Reject</button>
</form>
}
@if (item.Status is IllustrationLibraryStatuses.Generated or IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Rejected or IllustrationLibraryStatuses.Failed or IllustrationLibraryStatuses.Superseded)
{
<form asp-action="RegenerateIllustrationLibraryItem" asp-route-id="@item.IllustrationLibraryItemID" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Confirmed" value="true" />
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-outline-primary btn-sm">Regenerate</button>
</form>
}
@if (item.Status != IllustrationLibraryStatuses.Disabled)
{
<form asp-action="DisableIllustrationLibraryItem" asp-route-id="@item.IllustrationLibraryItemID" method="post" onsubmit="return confirm('Disable this library illustration?');">
@Html.AntiForgeryToken()
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-outline-secondary btn-sm">Disable</button>
</form>
}
</div>
</article>
}
</section>
@if (Model.Items.Count == 0)
{
<section class="edit-panel">
<p class="mb-0">No library items match this filter.</p>
</section>
}

View File

@ -0,0 +1,103 @@
# Phase 21C - Story Intelligence Illustration Library and Generation Studio
## Summary
Phase 21C adds an admin-only Illustration Library for reusable Story Intelligence visual identifiers. Library illustrations are representative visual identifiers for categories such as characters, locations and assets. They are not canonical manuscript-specific artwork and must not include private manuscript text, names or excerpts in provider prompts.
## Architecture
- `IllustrationLibraryItems` stores durable library records, generation history, approval state, prompt audit data, provider metadata and file metadata.
- `IllustrationGenerationSpecification` is the structured request model. Admins work from planned specs rather than writing raw prompts.
- `IllustrationPromptBuilder` converts a spec into the versioned template `21C.1`, preserving both the prompt and original spec for audit.
- `IIllustrationImageProvider` abstracts image generation. `OpenAIIllustrationImageProvider` uses server-side configuration only.
- `IllustrationGenerationWorker` processes queued items in the background, one item at a time, so browser sessions do not need to remain open.
- `IIllustrationLibraryStorageService` writes generated WebP images and thumbnails under the persistent uploads root.
## Database
The SQL script is `PlotLine/Sql/135_Phase21C_IllustrationLibrary.sql`.
The primary table is `dbo.IllustrationLibraryItems`. It records category, stable code, display title, description, metadata JSON, generation spec JSON, file paths, dimensions, file size, MIME type, lifecycle status, approval details, provider/model/request IDs, prompt text, prompt template version, attempts, parent lineage, rejection/admin notes, usage count and timestamps.
Statuses are:
- `Planned`
- `Queued`
- `Generating`
- `Generated`
- `Approved`
- `Rejected`
- `Failed`
- `Disabled`
Stored procedures support list, summary, get, upsert planned spec, queue, claim next queued item, mark generated, mark failed, approve, reject, disable, update metadata and list approved items.
## Storage
Generated files are stored below:
`/uploads/story-intelligence-library/{characters|locations|assets}/{stable-code}/`
The service writes WebP standard images and 256px thumbnail crops. Existing character, asset, floor-plan and cover uploads are unchanged.
## Provider And Configuration
Image generation uses the existing OpenAI/Story Intelligence options pattern:
- `OpenAI:ApiKey`
- `StoryIntelligence:ImageGenerationModel`
- `StoryIntelligence:ImageGenerationSize` optional, defaulting to `1024x1024`
If configuration is missing, the app starts normally and the Studio shows a clear not-configured message. Queue items will fail individually with a stored error instead of breaking startup.
## Admin Studio
The Studio route is:
`/admin/story-intelligence/illustration-library`
It is protected by the existing `AdminOnly` policy. The page shows counts, filters, grid cards, preview image, prompt/metadata audit data, provider/model/date state, and actions for starter planning, queuing, approval, rejection, disabling and metadata notes.
Costly generation actions require confirmation in the UI. Normal Story Intelligence imports do not trigger image generation.
## Starter Definitions
The starter batch contains 28 generic reusable specs:
- 12 character identifiers
- 6 location identifiers
- 10 asset identifiers
The specs are intentionally generic. They include representative subjects such as a young observer, witness, weathered man, estate house exterior, small flat interior, folded letter, worn notebook and red classic car.
## Prototype Integration
`/Development/StoryIntelligenceExperience` now carries stable library codes for prototype characters, locations and assets. The prototype keeps its existing SVG mock imagery as fallback. If an approved library item exists for a matching code, the approved uploaded illustration is used instead.
No real Story Intelligence pipeline data is connected to the library in this phase.
## Approval And Regeneration
Approval is explicit. Generated images do not become visible to the prototype until an admin approves them.
Regeneration is represented by re-queueing eligible planned, rejected or failed records and retaining previous generation metadata. Parent lineage fields are present for fuller future regeneration history.
## Security And Privacy
- Admin route only.
- Provider calls are server-side only.
- Secrets are not stored in source, JavaScript, SQL or docs.
- Prompt template forbids text, logos, watermarks, manuscript excerpts, real names and private manuscript details.
- Tests do not make paid provider calls.
## Deferred CRUD Integration
The library is prepared for future links to real character, location and asset CRUD through stable codes and usage counts. This phase does not alter existing entity upload workflows or Story Intelligence import behaviour.
## How To Run The Starter Batch
1. Apply `PlotLine/Sql/135_Phase21C_IllustrationLibrary.sql`.
2. Open `/admin/story-intelligence/illustration-library`.
3. Choose `Create starter plan`.
4. Confirm `Queue planned items` only after image generation configuration is present and cost is expected.
5. Review generated images and approve only the reusable identifiers that meet the style and privacy requirements.

View File

@ -0,0 +1,140 @@
# Phase 21D - Story Intelligence Illustration Library and Generation Studio
## Summary
Phase 21D hardens the Illustration Library into an admin-controlled generation workflow for reusable Story Intelligence artwork. Library illustrations are representative visual identifiers selected to aid recognition and navigation. They do not define or replace the author's own image of a character, location or asset.
## SQL Script
The Phase 21D migration is:
`PlotLine/Sql/136_Phase21D_IllustrationLibraryGenerationStudio.sql`
It extends the Phase 21C table with Phase 21D names and audit fields, including `DisplayName`, `Description`, `Prompt`, `GenerationAttemptCount`, `ErrorMessage`, `RejectedUtc`, `RejectedByUserID` and `Superseded` status support. It also adds regeneration through `IllustrationLibraryItem_CreateRegeneration`.
## Data Model
`IllustrationLibraryItems` stores durable illustration records:
- Category and stable code
- Display name and description
- Structured specification JSON
- Final prompt and prompt template version
- Provider, model and request metadata
- Status and generation attempt count
- File and thumbnail paths, dimensions, MIME type and file size
- Approval, rejection and lineage fields
- Active flag and timestamps
Categories are `Character`, `Location` and `Asset`.
Statuses are `Planned`, `Queued`, `Generating`, `Generated`, `Approved`, `Rejected`, `Failed`, `Superseded` and the operational disabled state.
## Specification Model
Images are defined by `IllustrationGenerationSpecification`. It contains shared fields plus typed category fields:
- Character: apparent age band, presentation, hair colour, hair length, skin tone, features, clothing era and clothing style.
- Location: location type, era and setting.
- Asset: object type, colour and era.
Validation is centralised on the specification model and exercised by tests.
## Prompt Builder
`IllustrationPromptBuilder` uses template version `21D.1`. It applies one shared style template and category-specific composition rules. It forbids manuscript names, excerpts, visible text, watermarks, logos, anime/cartoon/Pixar-like styling and photoreal identity claims.
## Provider Abstraction
`IIllustrationImageProvider` exposes provider name, model name, configuration status, generation, metadata and failure details. The OpenAI implementation uses the existing server-side `StoryIntelligenceOptions` binding, including:
- `OpenAI:ApiKey`
- `StoryIntelligence:ImageGenerationModel`
- `StoryIntelligence:ImageGenerationSize`
No browser-side image calls are used, and tests do not call the paid Images API.
## Queue Behaviour
Generation runs through `IllustrationGenerationWorker`. It claims persisted queued items with row locks, increments generation attempts and processes one item at a time. Failures are stored per item and do not stop the batch. Attempts are capped in SQL to avoid an infinite retry loop.
## Persistent Storage
Generated images are stored under the persistent uploads root:
`/uploads/story-intelligence-library/{characters|locations|assets}/{stable-code}/`
The service writes WebP source images and 256px thumbnails using SkiaSharp. Existing user uploads for characters, assets, covers and floor plans are unchanged.
## Admin Studio
Route:
`/admin/story-intelligence/illustration-library`
The Studio is protected by the existing `AdminOnly` policy. It provides counts, search, category and status filters, preview, specification JSON, prompt, provider/model data, errors, approval and rejection information, and actions to create starter specs, queue generation, approve, reject, regenerate and disable.
## Starter Specifications
The starter set contains:
- 12 characters
- 6 locations
- 10 assets
They cover varied age bands, presentations, appearances, location types and object types. They are generic reusable specifications, not Alpha Flame-specific artwork.
## Approval And Regeneration
Only approved illustrations can be resolved outside the admin review screen. Regeneration creates a new planned child item and preserves the prior result. Approving a replacement marks any previous approved item for the same stable code as `Superseded`.
## Prototype Integration
`/Development/StoryIntelligenceExperience` resolves images by stable library code. If an approved match exists, it uses the library image. Otherwise, the existing SVG prototype artwork remains the fallback.
The real Story Intelligence pipeline is not connected to this feature in Phase 21D.
## Cost Controls
Generation is admin-only and requires confirmation. The Studio shows image counts rather than invented cost estimates. Normal imports never generate images. Missing provider configuration is shown clearly and does not block application startup.
## Tests
The test harness covers specification validation, prompt construction, category validation, status transitions, path safety and starter-batch counts. Provider calls are not made by automated tests.
## Deployment Permissions
The service account must be able to write under the configured persistent `UploadsRoot`. For local development this is the existing PlotDirector upload storage exposed at `/uploads`.
## First Three Test Images
1. Apply SQL scripts through `136_Phase21D_IllustrationLibraryGenerationStudio.sql`.
2. Open `/admin/story-intelligence/illustration-library`.
3. Choose `Create starter plan`.
4. Confirm image generation configuration is present.
5. Filter or search for one character, one location and one asset.
6. Queue those three items individually.
7. Review the generated images for style, cropping and consistency.
8. Approve, reject or regenerate each image from the Studio.
Do not approve automatically.
## Full Starter Batch
1. Complete the first three-image review successfully.
2. Return to `/admin/story-intelligence/illustration-library`.
3. Confirm `Queue planned items`.
4. Monitor queued, generated and failed counts.
5. Review generated images in the Studio.
6. Approve only images that meet the style, privacy and cropping requirements.
## Deferred CRUD Integration
Future Character, Location and Story Asset pages should support:
1. User-uploaded image
2. User-selected library illustration
3. Automatically suggested library illustration
4. Existing fallback icon
This phase does not redesign those CRUD pages and does not alter existing user-upload behaviour.

View File

@ -0,0 +1,53 @@
# Phase 21D.1 - Automatic Starter Illustration Generation
## Summary
Phase 21D.1 changes the Illustration Library starter workflow from a two-step planning and queuing process to one admin-confirmed action: `Generate Starter Library`.
The action creates any missing starter specifications, queues eligible planned or failed starter items, skips successful or already-running items, and lets the existing background worker generate images after the browser closes.
## One-Click Workflow
Admin route:
`/admin/story-intelligence/illustration-library`
The primary action is `Generate Starter Library`. It confirms the number of character, location and asset images that will be queued.
Eligible starter items are:
- Missing starter specifications
- Existing `Planned` starter items
- Existing `Failed` starter items
Skipped starter items are:
- `Queued`
- `Generating`
- `Generated`
- `Approved`
This prevents accidental duplicate submissions and avoids regenerating successful artwork unnecessarily.
## Prototype Activation
The development Story Intelligence prototype resolves starter illustrations in this order:
1. Approved starter illustration
2. Generated starter illustration
3. Existing SVG fallback
This is limited to the development prototype route and does not weaken future production approval workflows.
## Configuration Diagnostics
The OpenAI image provider now reports specific missing settings without exposing secrets:
- OpenAI API key
- image-generation model
The local development service has `OpenAI__ApiKey` configured through `/etc/plotdirector/plotdirector.env`, but no image-generation model setting is currently present.
## Tests
The test harness now covers starter generation planning, duplicate protection for successful starter items, failed-item retry eligibility and provider missing-configuration diagnostics.