Compare commits
5 Commits
b214fbad4e
...
07a5477034
| Author | SHA1 | Date | |
|---|---|---|---|
| 07a5477034 | |||
| 57df5646e6 | |||
| e3d18b4a6b | |||
| 0ebccf9ac8 | |||
| f4e6af844b |
@ -1,4 +1,8 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
|
|
||||||
@ -21,7 +25,18 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
|
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
|
||||||
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
|
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
|
||||||
("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
|
("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)
|
foreach (var test in tests)
|
||||||
@ -314,6 +329,190 @@ static string CanonicalKnowledgeKey(string statement)
|
|||||||
return (string)method!.Invoke(null, [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()
|
static JsonSerializerOptions JsonOptions()
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
@ -328,3 +527,9 @@ static void Assert(bool condition, string message)
|
|||||||
throw new InvalidOperationException(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));
|
||||||
|
}
|
||||||
|
|||||||
@ -19,7 +19,8 @@ public sealed class AdminController(
|
|||||||
IStoryIntelligenceResultPersistenceService storyIntelligenceResults,
|
IStoryIntelligenceResultPersistenceService storyIntelligenceResults,
|
||||||
IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
|
IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
|
||||||
IStoryIntelligenceFullChapterTestService fullChapterTest,
|
IStoryIntelligenceFullChapterTestService fullChapterTest,
|
||||||
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit) : Controller
|
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
|
||||||
|
IIllustrationLibraryService illustrationLibrary) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
[HttpGet("index")]
|
[HttpGet("index")]
|
||||||
@ -37,6 +38,121 @@ public sealed class AdminController(
|
|||||||
return View();
|
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")]
|
[HttpGet("story-intelligence-diagnostics")]
|
||||||
public async Task<IActionResult> StoryIntelligenceDiagnostics(CancellationToken cancellationToken)
|
public async Task<IActionResult> StoryIntelligenceDiagnostics(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
25
PlotLine/Controllers/DevelopmentController.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PlotLine.Services;
|
||||||
|
|
||||||
|
namespace PlotLine.Controllers;
|
||||||
|
|
||||||
|
[Authorize(Policy = "AdminOnly")]
|
||||||
|
[Route("Development")]
|
||||||
|
public sealed class DevelopmentController(
|
||||||
|
IWebHostEnvironment environment,
|
||||||
|
IIllustrationLibraryService illustrationLibrary) : Controller
|
||||||
|
{
|
||||||
|
[HttpGet("StoryIntelligenceExperience")]
|
||||||
|
public async Task<IActionResult> StoryIntelligenceExperience()
|
||||||
|
{
|
||||||
|
if (!environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = StoryIntelligenceExperiencePrototypeData.Build();
|
||||||
|
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
PlotLine/Data/IllustrationLibraryRepository.cs
Normal 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')
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,514 @@
|
|||||||
|
# PlotDirector Story Intelligence Experience
|
||||||
|
|
||||||
|
**Version:** Draft 2\
|
||||||
|
**Status:** Phase 21 Design Vision\
|
||||||
|
**Priority:** High
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Purpose
|
||||||
|
|
||||||
|
Replace the traditional Story Intelligence progress page with a living
|
||||||
|
visual experience that allows authors to watch PlotDirector build an
|
||||||
|
understanding of their manuscript.
|
||||||
|
|
||||||
|
This is **not** simply a better progress bar.
|
||||||
|
|
||||||
|
It is intended to become one of PlotDirector's defining features, giving
|
||||||
|
authors confidence that the AI genuinely understands their story while
|
||||||
|
analysis is taking place.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Design Philosophy
|
||||||
|
|
||||||
|
## Story before structure
|
||||||
|
|
||||||
|
The Story Intelligence Experience is **not** intended to display a
|
||||||
|
complete technical graph of every entity in the manuscript.
|
||||||
|
|
||||||
|
Its purpose is to visualise PlotDirector's current understanding of the
|
||||||
|
story in a way that feels alive, engaging and emotionally recognisable.
|
||||||
|
|
||||||
|
The screen should always answer:
|
||||||
|
|
||||||
|
> **"What story is PlotDirector understanding right now?"**
|
||||||
|
|
||||||
|
rather than:
|
||||||
|
|
||||||
|
> **"What data currently exists?"**
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Narrative focus
|
||||||
|
|
||||||
|
The display should behave like a camera following the narrative.
|
||||||
|
|
||||||
|
As each scene completes, the visual emphasis naturally shifts towards
|
||||||
|
the people, places, objects and relationships that matter in that part
|
||||||
|
of the story.
|
||||||
|
|
||||||
|
Everything should not receive equal importance.
|
||||||
|
|
||||||
|
The visualisation should constantly rebalance around the current
|
||||||
|
narrative focus.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Current understanding only
|
||||||
|
|
||||||
|
The visualisation always represents PlotDirector's **best current
|
||||||
|
understanding**.
|
||||||
|
|
||||||
|
It is **not** intended to preserve historical graph states.
|
||||||
|
|
||||||
|
As new scenes are analysed, discoveries simply update the current
|
||||||
|
picture.
|
||||||
|
|
||||||
|
When Story Intelligence completes and the author approves the imported
|
||||||
|
data, the visualisation naturally transitions to using canonical
|
||||||
|
PlotDirector data.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Demonstrate understanding, not AI
|
||||||
|
|
||||||
|
The purpose of the experience is not to impress users with AI
|
||||||
|
processing.
|
||||||
|
|
||||||
|
It is to demonstrate understanding.
|
||||||
|
|
||||||
|
The author should finish watching believing:
|
||||||
|
|
||||||
|
> PlotDirector genuinely understands my novel.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Overall User Flow
|
||||||
|
|
||||||
|
Welcome
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Project
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Book
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Word Companion
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Scan manuscript
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Review detected chapters
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Start Story Intelligence
|
||||||
|
|
||||||
|
At this point the wizard ends.
|
||||||
|
|
||||||
|
The user chooses one of two experiences.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Experience One -- Continue in Background
|
||||||
|
|
||||||
|
Ideal for users who simply wish to import.
|
||||||
|
|
||||||
|
Analysis continues entirely server-side.
|
||||||
|
|
||||||
|
Closing Word or the browser never interrupts analysis.
|
||||||
|
|
||||||
|
Completion provides:
|
||||||
|
|
||||||
|
- Email notification
|
||||||
|
- In-app notification
|
||||||
|
- Resume from the Projects page
|
||||||
|
- Review Imported Scenes
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Experience Two -- Watch Story Intelligence
|
||||||
|
|
||||||
|
A dedicated full-screen experience.
|
||||||
|
|
||||||
|
Minimal chrome.
|
||||||
|
|
||||||
|
No wizard.
|
||||||
|
|
||||||
|
No forms.
|
||||||
|
|
||||||
|
No distractions.
|
||||||
|
|
||||||
|
Think:
|
||||||
|
|
||||||
|
- Mission Control
|
||||||
|
- A modern strategy game
|
||||||
|
- Apple-quality animation
|
||||||
|
- Calm, cinematic motion
|
||||||
|
|
||||||
|
This is the visual representation of PlotDirector reading and
|
||||||
|
understanding the author's story.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Screen Layout
|
||||||
|
|
||||||
|
## Header
|
||||||
|
|
||||||
|
- PlotDirector
|
||||||
|
- Story Intelligence
|
||||||
|
- Current Project
|
||||||
|
- Current Book
|
||||||
|
- Analysis Stage
|
||||||
|
- Continue in Background
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Left Panel
|
||||||
|
|
||||||
|
Administrative information.
|
||||||
|
|
||||||
|
Contains:
|
||||||
|
|
||||||
|
- Overall progress
|
||||||
|
- Percentage complete
|
||||||
|
- Estimated remaining time
|
||||||
|
- Chapters analysed
|
||||||
|
- Scenes analysed
|
||||||
|
- Current stage
|
||||||
|
- Latest discoveries
|
||||||
|
- Live activity feed
|
||||||
|
|
||||||
|
This area should feel informative rather than technical.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Centre
|
||||||
|
|
||||||
|
The Living Story View.
|
||||||
|
|
||||||
|
This is the heart of the experience.
|
||||||
|
|
||||||
|
It should not attempt to display the whole book equally.
|
||||||
|
|
||||||
|
Instead it presents the current centre of gravity of the narrative.
|
||||||
|
|
||||||
|
The display should naturally revolve around:
|
||||||
|
|
||||||
|
- Current POV character
|
||||||
|
- Important scene characters
|
||||||
|
- Active location
|
||||||
|
- Story-significant assets
|
||||||
|
- Current relationships
|
||||||
|
- Active knowledge threads
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Bottom Centre
|
||||||
|
|
||||||
|
Recent scenes.
|
||||||
|
|
||||||
|
The current scene occupies the centre.
|
||||||
|
|
||||||
|
As scenes complete they gently move left.
|
||||||
|
|
||||||
|
New scenes appear on the right.
|
||||||
|
|
||||||
|
This should feel like the story is progressing rather than a queue being
|
||||||
|
processed.
|
||||||
|
|
||||||
|
Beneath this is the timeline, gradually filling with dates and important
|
||||||
|
events.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Right Panel
|
||||||
|
|
||||||
|
Higher-level understanding.
|
||||||
|
|
||||||
|
Contains:
|
||||||
|
|
||||||
|
- Recent relationship discoveries
|
||||||
|
- Knowledge threads
|
||||||
|
- Story observations
|
||||||
|
- Interesting narrative insights
|
||||||
|
|
||||||
|
These should feel insightful rather than diagnostic.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Living Story View
|
||||||
|
|
||||||
|
The visualisation should feel like a camera following the story.
|
||||||
|
|
||||||
|
Characters, locations and assets should dominate the display using
|
||||||
|
imagery rather than abstract icons.
|
||||||
|
|
||||||
|
The scene currently being analysed determines the focus.
|
||||||
|
|
||||||
|
As new discoveries occur:
|
||||||
|
|
||||||
|
- Characters appear.
|
||||||
|
- Locations appear.
|
||||||
|
- Assets appear.
|
||||||
|
- Relationships grow.
|
||||||
|
- Knowledge threads emerge.
|
||||||
|
- Timeline events appear.
|
||||||
|
|
||||||
|
The graph gently reorganises itself.
|
||||||
|
|
||||||
|
Nothing should feel abrupt.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Visual Hierarchy
|
||||||
|
|
||||||
|
Priority should naturally be:
|
||||||
|
|
||||||
|
1. POV character
|
||||||
|
2. Main characters in the current scene
|
||||||
|
3. Current location
|
||||||
|
4. Important story assets
|
||||||
|
5. Active relationships
|
||||||
|
6. Knowledge threads
|
||||||
|
7. Less significant supporting entities
|
||||||
|
|
||||||
|
Minor characters may remain visible but should naturally appear smaller
|
||||||
|
and less prominent.
|
||||||
|
|
||||||
|
The display should never become visually cluttered.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Imagery First
|
||||||
|
|
||||||
|
Portraits, locations and assets are fundamental to the emotional impact
|
||||||
|
of the experience.
|
||||||
|
|
||||||
|
Generic illustrated portraits and representative imagery are entirely
|
||||||
|
acceptable during Story Intelligence.
|
||||||
|
|
||||||
|
The author may later replace them with custom images.
|
||||||
|
|
||||||
|
The visualisation should avoid reducing important entities to anonymous
|
||||||
|
circles wherever imagery is available.
|
||||||
|
|
||||||
|
People connect emotionally with faces, places and objects.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Character Portraits
|
||||||
|
|
||||||
|
Initially use a curated built-in library.
|
||||||
|
|
||||||
|
Approximately 300--500 illustrations.
|
||||||
|
|
||||||
|
Categorised by:
|
||||||
|
|
||||||
|
- Age
|
||||||
|
- Gender
|
||||||
|
- Hair colour
|
||||||
|
- General appearance
|
||||||
|
|
||||||
|
These are visual identifiers, not canonical artwork.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Location Images
|
||||||
|
|
||||||
|
Representative imagery chosen from a curated library.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Detached house
|
||||||
|
- Victorian home
|
||||||
|
- Farm
|
||||||
|
- School
|
||||||
|
- Church
|
||||||
|
- Canal
|
||||||
|
- Flat
|
||||||
|
- Police station
|
||||||
|
|
||||||
|
The purpose is recognition rather than perfect accuracy.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Asset Images
|
||||||
|
|
||||||
|
Representative images for common story assets.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Car
|
||||||
|
- Letter
|
||||||
|
- Passport
|
||||||
|
- Suitcase
|
||||||
|
- Notebook
|
||||||
|
- Jewellery
|
||||||
|
- Backpack
|
||||||
|
- Weapon
|
||||||
|
|
||||||
|
Again, these are visual identifiers only.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Live Discovery Feed
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Character discovered
|
||||||
|
- Location discovered
|
||||||
|
- Asset discovered
|
||||||
|
- Relationship detected
|
||||||
|
- Timeline event identified
|
||||||
|
- Knowledge thread opened
|
||||||
|
|
||||||
|
Older discoveries gently fade as new discoveries arrive.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Story Observations
|
||||||
|
|
||||||
|
These should feel intelligent.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Rosie has become central to the story.
|
||||||
|
- Beth now appears in seven scenes.
|
||||||
|
- Several names may refer to the same person.
|
||||||
|
- A mystery thread has emerged.
|
||||||
|
|
||||||
|
Avoid technical language.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Current Scene
|
||||||
|
|
||||||
|
Display:
|
||||||
|
|
||||||
|
- Scene number
|
||||||
|
- Scene title (if available)
|
||||||
|
- Brief summary
|
||||||
|
- Current progress
|
||||||
|
|
||||||
|
When a scene completes it joins the timeline.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Timeline
|
||||||
|
|
||||||
|
A growing ribbon across the bottom.
|
||||||
|
|
||||||
|
Scenes move naturally from right to left.
|
||||||
|
|
||||||
|
Dates and events accumulate.
|
||||||
|
|
||||||
|
Later this same component becomes PlotDirector's permanent timeline.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Animation Philosophy
|
||||||
|
|
||||||
|
Every animation should communicate meaning.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Character appears → newly discovered.
|
||||||
|
- Relationship line grows → connection identified.
|
||||||
|
- Character grows → increased narrative importance.
|
||||||
|
- Timeline moves → scene completed.
|
||||||
|
- Knowledge thread appears → mystery recognised.
|
||||||
|
|
||||||
|
Avoid decorative animation.
|
||||||
|
|
||||||
|
Every movement should reinforce understanding.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Current-State Model
|
||||||
|
|
||||||
|
The visualisation always displays the current accumulated understanding.
|
||||||
|
|
||||||
|
There is no requirement to replay previous graph states.
|
||||||
|
|
||||||
|
If understanding changes, the visualisation simply updates.
|
||||||
|
|
||||||
|
The browser always shows the latest truth.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Technology Direction
|
||||||
|
|
||||||
|
Current preferred architecture:
|
||||||
|
|
||||||
|
- ASP.NET Core
|
||||||
|
- SQL Server
|
||||||
|
- SignalR
|
||||||
|
- Existing Story Intelligence results
|
||||||
|
- Interactive graph renderer
|
||||||
|
- HTML/CSS dashboard
|
||||||
|
- Smooth animation
|
||||||
|
|
||||||
|
The renderer itself is an implementation detail.
|
||||||
|
|
||||||
|
The user experience described in this document is the priority.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Completion
|
||||||
|
|
||||||
|
When Story Intelligence completes:
|
||||||
|
|
||||||
|
Story Intelligence Complete
|
||||||
|
|
||||||
|
Your manuscript is ready for review.
|
||||||
|
|
||||||
|
Buttons:
|
||||||
|
|
||||||
|
- Review Imported Scenes
|
||||||
|
- Explore Story Map
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Long-Term Vision
|
||||||
|
|
||||||
|
The technology developed here becomes the foundation for:
|
||||||
|
|
||||||
|
- Story Map
|
||||||
|
- Character Explorer
|
||||||
|
- Relationship Explorer
|
||||||
|
- Location Explorer
|
||||||
|
- Asset Explorer
|
||||||
|
- Timeline Explorer
|
||||||
|
- Knowledge Explorer
|
||||||
|
- Continuity Explorer
|
||||||
|
|
||||||
|
The Story Intelligence experience is cinematic.
|
||||||
|
|
||||||
|
The permanent Story Map is analytical.
|
||||||
|
|
||||||
|
Both share the same underlying story data while presenting it
|
||||||
|
differently.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Ultimate Design Goal
|
||||||
|
|
||||||
|
The author should never feel they are watching a progress bar.
|
||||||
|
|
||||||
|
They should feel they are watching their world come alive.
|
||||||
|
|
||||||
|
By the time analysis finishes they should have greater confidence that
|
||||||
|
PlotDirector genuinely understands the novel they have spent months or
|
||||||
|
years creating.
|
||||||
|
|
||||||
|
The visualisation should become one of PlotDirector's signature features
|
||||||
|
and something authors remember, recommend and enjoy watching.
|
||||||
263
PlotLine/Models/IllustrationLibraryModels.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -99,6 +99,8 @@ public sealed class StoryIntelligenceOptions
|
|||||||
public string Model { get; init; } = string.Empty;
|
public string Model { get; init; } = string.Empty;
|
||||||
public string ChapterStructureModel { get; init; } = string.Empty;
|
public string ChapterStructureModel { get; init; } = string.Empty;
|
||||||
public string SceneIntelligenceModel { 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 MaxOutputTokens { get; init; } = 4000;
|
||||||
public int? SceneIntelligenceMaxOutputTokens { get; init; }
|
public int? SceneIntelligenceMaxOutputTokens { get; init; }
|
||||||
public int TimeoutSeconds { get; init; } = 120;
|
public int TimeoutSeconds { get; init; } = 120;
|
||||||
|
|||||||
@ -144,6 +144,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
||||||
|
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
|
||||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||||
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
|
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
|
||||||
@ -216,6 +217,11 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||||
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
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<IProjectAccessService, ProjectAccessService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||||
@ -232,6 +238,7 @@ public class Program
|
|||||||
builder.Services.AddHostedService<EmailQueueWorker>();
|
builder.Services.AddHostedService<EmailQueueWorker>();
|
||||||
builder.Services.AddHostedService<StoryIntelligenceWorker>();
|
builder.Services.AddHostedService<StoryIntelligenceWorker>();
|
||||||
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
|
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
|
||||||
|
builder.Services.AddHostedService<IllustrationGenerationWorker>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
927
PlotLine/Services/IllustrationLibraryServices.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
233
PlotLine/Services/StoryIntelligenceExperiencePrototypeData.cs
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
using PlotLine.ViewModels;
|
||||||
|
|
||||||
|
namespace PlotLine.Services;
|
||||||
|
|
||||||
|
public static class StoryIntelligenceExperiencePrototypeData
|
||||||
|
{
|
||||||
|
private const string AssetRoot = "/images/story-intelligence/prototype";
|
||||||
|
|
||||||
|
public static StoryIntelligenceExperiencePrototypeViewModel Build()
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
ProjectName = "Alpha Flame",
|
||||||
|
BookTitle = "The Doweries Manuscript",
|
||||||
|
Scenes =
|
||||||
|
[
|
||||||
|
Scene(
|
||||||
|
"scene-01", 1, "A key turns in the rain", "Beth arrives at The Doweries and finds Maggie's letter waiting where it should not be.",
|
||||||
|
"Detecting scene structure", "Fri 18 Oct", "Beth reaches The Doweries", 8, 1, 12, "42 min",
|
||||||
|
"beth", Location("doweries", "The Doweries", "Rain-darkened estate house", "location-doweries.svg"),
|
||||||
|
Characters(("beth", "Beth", "POV character", "Searching", 100, "character-beth.svg"), ("maggie", "Maggie", "Absent presence", "Remembered", 42, "character-maggie.svg"), ("graham", "Graham", "Name in the letter", "Unseen", 26, "character-graham.svg")),
|
||||||
|
Assets(("letter", "Letter", "Unopened evidence", 88, "asset-letter.svg"), ("notebook", "Notebook", "Beth's notes", 54, "asset-notebook.svg")),
|
||||||
|
Relationships(("rel-beth-maggie", "beth", "maggie", "Family tie detected", "grief and loyalty", 72)),
|
||||||
|
Threads(("thread-maggie-secret", "Why did Maggie hide the letter?", "A secret begins around the flat and the missing key.", "mystery")),
|
||||||
|
Observations(("obs-arrival", "The story opens with withheld knowledge.", "The reader knows the letter matters before Beth understands why.", "insight")),
|
||||||
|
Discoveries("Possible character found: Maggie", "Location found: The Doweries", "Asset found: Letter"),
|
||||||
|
Feed("Chapter 1 queued", "Scene boundary detected", "Beth identified as point of view")),
|
||||||
|
Scene(
|
||||||
|
"scene-02", 2, "Maggie's flat", "Inside Maggie's flat, Beth finds signs that someone searched the room before her.",
|
||||||
|
"Reading scenes", "Fri 18 Oct", "Flat searched before Beth arrives", 17, 1, 12, "38 min",
|
||||||
|
"beth", Location("maggie-flat", "Maggie's flat", "A small flat inside The Doweries", "location-flat.svg"),
|
||||||
|
Characters(("beth", "Beth", "POV character", "Investigating", 100, "character-beth.svg"), ("maggie", "Maggie", "Absent presence", "Everywhere in the room", 64, "character-maggie.svg"), ("rosie", "Rosie", "Neighbour", "Possible witness", 38, "character-rosie.svg")),
|
||||||
|
Assets(("letter", "Letter", "Hidden in plain sight", 92, "asset-letter.svg"), ("rucksack", "Rucksack", "Recently moved", 72, "asset-rucksack.svg"), ("notebook", "Notebook", "Fresh page torn out", 58, "asset-notebook.svg")),
|
||||||
|
Relationships(("rel-beth-maggie", "beth", "maggie", "Relationship strengthened", "protective grief", 82), ("rel-beth-rosie", "beth", "rosie", "Witness connection", "cautious trust", 45)),
|
||||||
|
Threads(("thread-maggie-secret", "Maggie's missing page", "A torn notebook page suggests Maggie prepared for Beth's arrival.", "mystery"), ("thread-reader-knows", "Reader knows the flat was searched.", "Beth notices the disturbed objects but not yet the pattern.", "secret")),
|
||||||
|
Observations(("obs-flat", "The location narrows from estate to flat.", "PlotDirector treats Maggie's flat as the active location rather than showing the parent estate.", "insight")),
|
||||||
|
Discoveries("Location refined: Maggie's flat", "Possible asset found: Rucksack", "Knowledge thread opened: missing page"),
|
||||||
|
Feed("Scene 2 analysis started", "Flat recognised as child location", "Beth-Maggie relationship evidence increased")),
|
||||||
|
Scene(
|
||||||
|
"scene-03", 3, "Rosie at the landing", "Rosie interrupts Beth and reveals Graham visited Maggie two nights before.",
|
||||||
|
"Reading scenes", "Fri 18 Oct", "Graham placed near Maggie", 26, 2, 12, "34 min",
|
||||||
|
"beth", Location("maggie-flat", "Maggie's flat", "Landing light spilling through the open door", "location-flat.svg"),
|
||||||
|
Characters(("beth", "Beth", "POV character", "Questioning", 100, "character-beth.svg"), ("rosie", "Rosie", "Witness", "Nervous but helpful", 76, "character-rosie.svg"), ("graham", "Graham", "Named suspect", "Recently present", 58, "character-graham.svg"), ("maggie", "Maggie", "Absent presence", "Connected to Graham", 44, "character-maggie.svg")),
|
||||||
|
Assets(("letter", "Letter", "Still unread", 70, "asset-letter.svg"), ("notebook", "Notebook", "Confirms missing page", 62, "asset-notebook.svg")),
|
||||||
|
Relationships(("rel-beth-rosie", "beth", "rosie", "Relationship discovered", "cautious trust", 64), ("rel-graham-maggie", "graham", "maggie", "Possible conflict detected", "recent visit", 70)),
|
||||||
|
Threads(("thread-graham", "Why did Graham visit Maggie?", "Rosie's memory puts Graham at the flat before the search.", "question"), ("thread-maggie-secret", "Maggie's missing page", "The missing page may describe Graham's visit.", "mystery")),
|
||||||
|
Observations(("obs-rosie", "A witness changes the centre of gravity.", "Rosie grows because she supplies new causal information.", "insight")),
|
||||||
|
Discoveries("Possible relationship found: Graham and Maggie", "Character relevance increased: Rosie", "Timeline clue found: two nights before"),
|
||||||
|
Feed("Rosie promoted to important scene character", "Graham inferred from dialogue", "Relationship signal found")),
|
||||||
|
Scene(
|
||||||
|
"scene-04", 4, "The red TR6", "Beth spots Graham's red TR6 outside the estate and realises he has returned.",
|
||||||
|
"Extracting assets", "Fri 18 Oct", "Red TR6 returns to The Doweries", 36, 2, 12, "30 min",
|
||||||
|
"beth", Location("doweries", "The Doweries", "Driveway under sodium light", "location-doweries.svg"),
|
||||||
|
Characters(("beth", "Beth", "POV character", "Watching", 100, "character-beth.svg"), ("graham", "Graham", "Active threat", "Returned", 82, "character-graham.svg"), ("rosie", "Rosie", "Witness", "Off to the side", 36, "character-rosie.svg")),
|
||||||
|
Assets(("red-tr6", "Red TR6", "Graham's car", 96, "asset-red-tr6.svg"), ("letter", "Letter", "Unopened evidence", 56, "asset-letter.svg"), ("keys", "Keys", "Estate access", 48, "asset-keys.svg")),
|
||||||
|
Relationships(("rel-beth-graham", "beth", "graham", "Possible conflict detected", "avoidance", 78), ("rel-graham-maggie", "graham", "maggie", "Relationship strengthened", "unexplained visit", 74)),
|
||||||
|
Threads(("thread-graham", "Graham returns before Beth can leave.", "The car turns a memory into an immediate threat.", "question")),
|
||||||
|
Observations(("obs-tr6", "An asset becomes narrative evidence.", "The car is not background colour; it proves Graham is physically present.", "insight")),
|
||||||
|
Discoveries("Asset found: Red TR6", "Relationship signal found: Beth and Graham", "Location changed: The Doweries driveway"),
|
||||||
|
Feed("Scene 4 analysed", "Vehicle classified as significant asset", "Conflict confidence increased")),
|
||||||
|
Scene(
|
||||||
|
"scene-05", 5, "Graham on the stairs", "Graham blocks Beth on the stairwell and asks whether she has opened Maggie's letter.",
|
||||||
|
"Inferring relationships", "Fri 18 Oct", "Graham confronts Beth", 47, 3, 12, "26 min",
|
||||||
|
"beth", Location("stairwell", "The Doweries stairwell", "Narrow stairwell between flat and exit", "location-stairwell.svg"),
|
||||||
|
Characters(("beth", "Beth", "POV character", "Cornered", 100, "character-beth.svg"), ("graham", "Graham", "Opposing force", "Pressing", 96, "character-graham.svg"), ("maggie", "Maggie", "Absent pressure", "The reason for the question", 46, "character-maggie.svg")),
|
||||||
|
Assets(("letter", "Letter", "Graham wants it", 96, "asset-letter.svg"), ("keys", "Keys", "Exit blocked", 55, "asset-keys.svg")),
|
||||||
|
Relationships(("rel-beth-graham", "beth", "graham", "Relationship changed", "open conflict", 95), ("rel-graham-maggie", "graham", "maggie", "Suspicion strengthened", "knows about letter", 84)),
|
||||||
|
Threads(("thread-letter", "What is in Maggie's letter?", "Graham's question proves the letter contains something he fears.", "mystery"), ("thread-reader-knows", "Reader knows Graham is afraid of the letter.", "Beth now knows the letter has leverage.", "secret")),
|
||||||
|
Observations(("obs-stairs", "The relationship graph tightens.", "The scene is now Beth versus Graham, with the letter acting as pressure point.", "insight")),
|
||||||
|
Discoveries("Relationship changed: Beth and Graham", "Knowledge thread opened: letter contents", "Asset ownership pressure detected: Letter"),
|
||||||
|
Feed("Graham promoted to primary opposing character", "Letter confidence increased", "Conflict classified")),
|
||||||
|
Scene(
|
||||||
|
"scene-06", 6, "Rosie's warning", "Rosie pulls Beth into the laundry room and warns her not to trust Graham's version of Maggie.",
|
||||||
|
"Reading scenes", "Fri 18 Oct", "Rosie contradicts Graham", 58, 3, 12, "22 min",
|
||||||
|
"rosie", Location("laundry", "Laundry room", "Low-lit utility room behind the stairwell", "location-laundry.svg"),
|
||||||
|
Characters(("rosie", "Rosie", "POV character", "Warning Beth", 100, "character-rosie.svg"), ("beth", "Beth", "Protected listener", "Reassessing", 82, "character-beth.svg"), ("graham", "Graham", "Absent threat", "His story is challenged", 58, "character-graham.svg"), ("maggie", "Maggie", "Remembered", "Misrepresented", 42, "character-maggie.svg")),
|
||||||
|
Assets(("notebook", "Notebook", "Rosie saw it", 78, "asset-notebook.svg"), ("letter", "Letter", "Still decisive", 70, "asset-letter.svg")),
|
||||||
|
Relationships(("rel-beth-rosie", "beth", "rosie", "Relationship strengthened", "protective trust", 86), ("rel-rosie-graham", "rosie", "graham", "Possible conflict detected", "fear and mistrust", 74)),
|
||||||
|
Threads(("thread-graham", "Graham's account is unreliable.", "Rosie introduces contradiction and a motive to lie.", "question"), ("thread-letter", "The letter may expose Graham.", "Both Rosie and Graham orbit the same hidden evidence.", "mystery")),
|
||||||
|
Observations(("obs-pov-shift", "The POV changes to Rosie.", "The camera follows the emotional source of new information, not just the protagonist.", "insight")),
|
||||||
|
Discoveries("POV changed: Rosie", "Relationship strengthened: Beth and Rosie", "Contradictory evidence detected"),
|
||||||
|
Feed("Scene 6 started", "POV character changed", "Knowledge thread updated")),
|
||||||
|
Scene(
|
||||||
|
"scene-07", 7, "Notebook page", "Beth reconstructs the torn notebook page and links the missing line to the red TR6.",
|
||||||
|
"Building knowledge", "Fri 18 Oct", "Notebook and car clue connect", 69, 4, 12, "18 min",
|
||||||
|
"beth", Location("maggie-flat", "Maggie's flat", "Desk, lamplight and torn paper edge", "location-flat.svg"),
|
||||||
|
Characters(("beth", "Beth", "POV character", "Connecting clues", 100, "character-beth.svg"), ("maggie", "Maggie", "Absent author", "Left a trail", 70, "character-maggie.svg"), ("graham", "Graham", "Implied target", "Named by evidence", 50, "character-graham.svg")),
|
||||||
|
Assets(("notebook", "Notebook", "Torn page reconstructed", 96, "asset-notebook.svg"), ("red-tr6", "Red TR6", "Matches hidden note", 84, "asset-red-tr6.svg"), ("letter", "Letter", "Next evidence", 72, "asset-letter.svg")),
|
||||||
|
Relationships(("rel-beth-maggie", "beth", "maggie", "Relationship strengthened", "Maggie left Beth a trail", 90), ("rel-graham-maggie", "graham", "maggie", "Relationship changed", "direct evidence", 88)),
|
||||||
|
Threads(("thread-letter", "The letter is part of a trail.", "Maggie appears to have staged evidence for Beth to find.", "mystery"), ("thread-tr6", "The car links Graham to the missing page.", "Asset evidence connects two earlier scenes.", "secret")),
|
||||||
|
Observations(("obs-thread", "Knowledge threads begin to braid.", "PlotDirector links asset, relationship and location evidence into one story question.", "insight")),
|
||||||
|
Discoveries("Knowledge thread opened: Red TR6 link", "Relationship updated: Graham and Maggie", "Asset association found: Notebook and Red TR6"),
|
||||||
|
Feed("Scene 7 analysed", "Notebook promoted to key asset", "Cross-scene link inferred")),
|
||||||
|
Scene(
|
||||||
|
"scene-08", 8, "The call", "Beth hears Maggie's recorded message and realises the warning was meant for Rosie too.",
|
||||||
|
"Building knowledge", "Fri 18 Oct", "Recorded message expands the secret", 79, 4, 12, "14 min",
|
||||||
|
"beth", Location("maggie-flat", "Maggie's flat", "Phone speaker glowing on the table", "location-flat.svg"),
|
||||||
|
Characters(("beth", "Beth", "POV character", "Listening", 100, "character-beth.svg"), ("maggie", "Maggie", "Voice on recording", "Direct presence", 88, "character-maggie.svg"), ("rosie", "Rosie", "Named ally", "Also warned", 64, "character-rosie.svg"), ("graham", "Graham", "Threat", "Closing in", 54, "character-graham.svg")),
|
||||||
|
Assets(("letter", "Letter", "Names the danger", 86, "asset-letter.svg"), ("phone", "Phone", "Recorded message", 92, "asset-phone.svg"), ("notebook", "Notebook", "Supports warning", 66, "asset-notebook.svg")),
|
||||||
|
Relationships(("rel-beth-maggie", "beth", "maggie", "Relationship changed", "message from beyond absence", 94), ("rel-maggie-rosie", "maggie", "rosie", "Relationship discovered", "trusted ally", 72)),
|
||||||
|
Threads(("thread-letter", "Maggie's warning has two recipients.", "The secret is bigger than Beth's inheritance of the letter.", "mystery"), ("thread-reader-knows", "Reader now knows Rosie was part of Maggie's plan.", "Beth understands Rosie differently.", "secret")),
|
||||||
|
Observations(("obs-message", "An absent character becomes visually central.", "Maggie's portrait grows because the scene gives her direct agency.", "insight")),
|
||||||
|
Discoveries("Relationship discovered: Maggie and Rosie", "Asset found: Phone recording", "Observation created: absent character has agency"),
|
||||||
|
Feed("Scene 8 analysed", "Maggie promoted through recorded evidence", "Knowledge thread expanded")),
|
||||||
|
Scene(
|
||||||
|
"scene-09", 9, "Car park exit", "Beth and Rosie try to leave, but the red TR6 is parked across the only exit.",
|
||||||
|
"Inferring continuity", "Fri 18 Oct", "Exit blocked", 89, 5, 12, "9 min",
|
||||||
|
"beth", Location("car-park", "The Doweries car park", "Wet tarmac and one blocked exit", "location-car-park.svg"),
|
||||||
|
Characters(("beth", "Beth", "POV character", "Deciding", 100, "character-beth.svg"), ("rosie", "Rosie", "Ally", "At Beth's side", 84, "character-rosie.svg"), ("graham", "Graham", "Threat", "Controls the exit", 78, "character-graham.svg")),
|
||||||
|
Assets(("red-tr6", "Red TR6", "Blocks escape", 98, "asset-red-tr6.svg"), ("letter", "Letter", "Motive for pursuit", 74, "asset-letter.svg"), ("keys", "Keys", "Possible escape route", 62, "asset-keys.svg")),
|
||||||
|
Relationships(("rel-beth-rosie", "beth", "rosie", "Relationship strengthened", "active alliance", 92), ("rel-beth-graham", "beth", "graham", "Relationship intensified", "physical obstruction", 98)),
|
||||||
|
Threads(("thread-exit", "How do Beth and Rosie leave?", "The location and asset combine into an immediate continuity problem.", "question"), ("thread-letter", "Graham's pursuit confirms the letter's value.", "The hidden evidence now drives action.", "mystery")),
|
||||||
|
Observations(("obs-blocked", "Location, asset and relationship align.", "The car park is important because the red TR6 changes what characters can do.", "insight")),
|
||||||
|
Discoveries("Continuity observation: exit blocked", "Relationship intensified: Beth and Graham", "Asset state changed: Red TR6 blocks exit"),
|
||||||
|
Feed("Scene 9 analysed", "Location changed to car park", "Asset movement inferred")),
|
||||||
|
Scene(
|
||||||
|
"scene-10", 10, "The opened letter", "Beth opens Maggie's letter and the hidden pattern around Graham finally becomes explicit.",
|
||||||
|
"Preparing review", "Fri 18 Oct", "Letter reveals Graham's motive", 100, 5, 12, "Ready to review",
|
||||||
|
"beth", Location("car-park", "The Doweries car park", "Car lights, rain and an opened letter", "location-car-park.svg"),
|
||||||
|
Characters(("beth", "Beth", "POV character", "Understands", 100, "character-beth.svg"), ("maggie", "Maggie", "Truth teller", "Her plan revealed", 90, "character-maggie.svg"), ("rosie", "Rosie", "Ally", "Confirmed", 78, "character-rosie.svg"), ("graham", "Graham", "Exposed", "Motive visible", 74, "character-graham.svg")),
|
||||||
|
Assets(("letter", "Letter", "Opened evidence", 100, "asset-letter.svg"), ("red-tr6", "Red TR6", "Proof of presence", 76, "asset-red-tr6.svg"), ("notebook", "Notebook", "Corroborates letter", 68, "asset-notebook.svg")),
|
||||||
|
Relationships(("rel-beth-maggie", "beth", "maggie", "Relationship resolved", "trusted inheritance", 96), ("rel-beth-rosie", "beth", "rosie", "Relationship confirmed", "alliance", 90), ("rel-beth-graham", "beth", "graham", "Conflict confirmed", "motive exposed", 98)),
|
||||||
|
Threads(("thread-letter", "Maggie's letter explains Graham's motive.", "The central question becomes review-ready.", "mystery"), ("thread-tr6", "The Red TR6 evidence is confirmed.", "The car, notebook and letter point to the same conclusion.", "secret")),
|
||||||
|
Observations(("obs-complete", "PlotDirector has a coherent story map for this sequence.", "Characters, location, assets and relationships now tell one readable story.", "insight")),
|
||||||
|
Discoveries("Analysis complete: 10 scenes ready for review", "Relationship confirmed: Beth and Rosie", "Knowledge thread resolved: Letter contents"),
|
||||||
|
Feed("Scene 10 analysed", "Chapter sequence ready", "Review data prepared"))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
private static StoryIntelligenceExperienceSceneState Scene(
|
||||||
|
string id,
|
||||||
|
int number,
|
||||||
|
string title,
|
||||||
|
string summary,
|
||||||
|
string stage,
|
||||||
|
string timelineLabel,
|
||||||
|
string timelineEvent,
|
||||||
|
int progress,
|
||||||
|
int chapters,
|
||||||
|
int sceneTotal,
|
||||||
|
string remaining,
|
||||||
|
string pov,
|
||||||
|
StoryIntelligenceExperienceLocationState location,
|
||||||
|
IReadOnlyList<StoryIntelligenceExperienceCharacterState> characters,
|
||||||
|
IReadOnlyList<StoryIntelligenceExperienceAssetState> assets,
|
||||||
|
IReadOnlyList<StoryIntelligenceExperienceRelationshipState> relationships,
|
||||||
|
IReadOnlyList<StoryIntelligenceExperienceTextItem> threads,
|
||||||
|
IReadOnlyList<StoryIntelligenceExperienceTextItem> observations,
|
||||||
|
IReadOnlyList<string> discoveries,
|
||||||
|
IReadOnlyList<string> feed)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
SceneNumber = number,
|
||||||
|
Title = title,
|
||||||
|
Summary = summary,
|
||||||
|
Stage = stage,
|
||||||
|
TimelineLabel = timelineLabel,
|
||||||
|
TimelineEvent = timelineEvent,
|
||||||
|
ProgressPercent = progress,
|
||||||
|
ChaptersAnalysed = chapters,
|
||||||
|
ChapterTotal = 5,
|
||||||
|
ScenesAnalysed = number,
|
||||||
|
SceneTotal = sceneTotal,
|
||||||
|
EstimatedRemaining = remaining,
|
||||||
|
PovCharacterId = pov,
|
||||||
|
Location = location,
|
||||||
|
Characters = characters,
|
||||||
|
Assets = assets,
|
||||||
|
Relationships = relationships,
|
||||||
|
KnowledgeThreads = threads,
|
||||||
|
Observations = observations,
|
||||||
|
LatestDiscoveries = discoveries,
|
||||||
|
ActivityFeed = feed
|
||||||
|
};
|
||||||
|
|
||||||
|
private static StoryIntelligenceExperienceLocationState Location(string id, string name, string label, string 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, 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, 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();
|
||||||
|
|
||||||
|
private static IReadOnlyList<StoryIntelligenceExperienceTextItem> Threads(params (string Id, string Title, string Detail, string Tone)[] items)
|
||||||
|
=> TextItems(items);
|
||||||
|
|
||||||
|
private static IReadOnlyList<StoryIntelligenceExperienceTextItem> Observations(params (string Id, string Title, string Detail, string Tone)[] items)
|
||||||
|
=> TextItems(items);
|
||||||
|
|
||||||
|
private static IReadOnlyList<StoryIntelligenceExperienceTextItem> TextItems(params (string Id, string Title, string Detail, string Tone)[] items)
|
||||||
|
=> items.Select(item => new StoryIntelligenceExperienceTextItem { Id = item.Id, Title = item.Title, Detail = item.Detail, Tone = item.Tone }).ToList();
|
||||||
|
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
}
|
||||||
383
PlotLine/Sql/135_Phase21C_IllustrationLibrary.sql
Normal 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
|
||||||
@ -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
|
||||||
69
PlotLine/ViewModels/IllustrationLibraryViewModels.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
namespace PlotLine.ViewModels;
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceExperiencePrototypeViewModel
|
||||||
|
{
|
||||||
|
public string ProjectName { get; init; } = string.Empty;
|
||||||
|
public string BookTitle { get; init; } = string.Empty;
|
||||||
|
public IReadOnlyList<StoryIntelligenceExperienceSceneState> Scenes { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceExperienceSceneState
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
public int SceneNumber { get; init; }
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public string Summary { get; init; } = string.Empty;
|
||||||
|
public string Stage { get; init; } = string.Empty;
|
||||||
|
public string TimelineLabel { get; init; } = string.Empty;
|
||||||
|
public string TimelineEvent { get; init; } = string.Empty;
|
||||||
|
public int ProgressPercent { get; init; }
|
||||||
|
public int ChaptersAnalysed { get; init; }
|
||||||
|
public int ChapterTotal { get; init; }
|
||||||
|
public int ScenesAnalysed { get; init; }
|
||||||
|
public int SceneTotal { get; init; }
|
||||||
|
public string EstimatedRemaining { get; init; } = string.Empty;
|
||||||
|
public string PovCharacterId { get; init; } = string.Empty;
|
||||||
|
public StoryIntelligenceExperienceLocationState Location { get; init; } = new();
|
||||||
|
public IReadOnlyList<StoryIntelligenceExperienceCharacterState> Characters { get; init; } = [];
|
||||||
|
public IReadOnlyList<StoryIntelligenceExperienceAssetState> Assets { get; init; } = [];
|
||||||
|
public IReadOnlyList<StoryIntelligenceExperienceRelationshipState> Relationships { get; init; } = [];
|
||||||
|
public IReadOnlyList<StoryIntelligenceExperienceTextItem> KnowledgeThreads { get; init; } = [];
|
||||||
|
public IReadOnlyList<StoryIntelligenceExperienceTextItem> Observations { get; init; } = [];
|
||||||
|
public IReadOnlyList<string> LatestDiscoveries { get; init; } = [];
|
||||||
|
public IReadOnlyList<string> ActivityFeed { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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; 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; 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; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceExperienceRelationshipState
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
public string SourceId { get; init; } = string.Empty;
|
||||||
|
public string TargetId { get; init; } = string.Empty;
|
||||||
|
public string Label { get; init; } = string.Empty;
|
||||||
|
public string State { get; init; } = string.Empty;
|
||||||
|
public int Weight { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceExperienceTextItem
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public string Detail { get; init; } = string.Empty;
|
||||||
|
public string Tone { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@ -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="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="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="StoryIntelligenceRuns">Saved runs</a>
|
||||||
|
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceIllustrationLibrary">Illustration library</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="project-card">
|
<article class="project-card">
|
||||||
|
|||||||
241
PlotLine/Views/Admin/StoryIntelligenceIllustrationLibrary.cshtml
Normal 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>
|
||||||
|
}
|
||||||
146
PlotLine/Views/Development/StoryIntelligenceExperience.cshtml
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
@using System.Text.Json
|
||||||
|
@model StoryIntelligenceExperiencePrototypeViewModel
|
||||||
|
@{
|
||||||
|
Layout = null;
|
||||||
|
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||||
|
}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Story Intelligence Experience Prototype - PlotDirector</title>
|
||||||
|
<link rel="stylesheet" href="~/css/story-intelligence-experience-prototype.css" asp-append-version="true" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script>
|
||||||
|
|
||||||
|
<main class="story-exp" data-story-experience>
|
||||||
|
<header class="story-exp-header" aria-label="Story Intelligence status">
|
||||||
|
<div class="story-exp-brand">
|
||||||
|
<strong>PlotDirector</strong>
|
||||||
|
<span>Story Intelligence</span>
|
||||||
|
</div>
|
||||||
|
<ol class="story-exp-pipeline" data-analysis-stages aria-label="Analysis stages"></ol>
|
||||||
|
<div class="story-exp-context">
|
||||||
|
<span>@Model.ProjectName</span>
|
||||||
|
<span>@Model.BookTitle</span>
|
||||||
|
<span data-stage-label>Preparing</span>
|
||||||
|
<span>Scene <strong data-current-scene>1</strong></span>
|
||||||
|
</div>
|
||||||
|
<button class="story-exp-background" type="button">Continue in Background</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="story-exp-shell" aria-label="Story Intelligence visual prototype">
|
||||||
|
<aside class="story-exp-left" aria-label="Analysis progress">
|
||||||
|
<div class="story-exp-progress">
|
||||||
|
<p class="story-exp-eyebrow">Overall progress</p>
|
||||||
|
<div class="story-exp-progress-ring" aria-hidden="true">
|
||||||
|
<div class="story-exp-progress-orbit"></div>
|
||||||
|
<div class="story-exp-progress-core">
|
||||||
|
<strong data-progress-percent>0%</strong>
|
||||||
|
<span>complete</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="story-exp-progress-times">
|
||||||
|
<div>
|
||||||
|
<span>Elapsed</span>
|
||||||
|
<strong data-elapsed-time>0m</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Remaining</span>
|
||||||
|
<strong data-estimated-remaining>Calculating</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="story-exp-stats">
|
||||||
|
<div>
|
||||||
|
<dt>Chapters analysed</dt>
|
||||||
|
<dd data-chapter-count>0 of 5</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Scenes analysed</dt>
|
||||||
|
<dd data-scene-count>0 of 12</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Current stage</dt>
|
||||||
|
<dd data-current-stage>Preparing</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<section class="story-exp-panel" aria-labelledby="latest-discoveries-title">
|
||||||
|
<h2 id="latest-discoveries-title">Latest discoveries</h2>
|
||||||
|
<ul class="story-exp-list" data-discoveries></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="story-exp-panel" aria-labelledby="activity-feed-title">
|
||||||
|
<h2 id="activity-feed-title">Live activity</h2>
|
||||||
|
<ol class="story-exp-feed" data-activity-feed></ol>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="story-exp-stage" aria-labelledby="living-story-title">
|
||||||
|
<div class="story-exp-stage-copy">
|
||||||
|
<p class="story-exp-eyebrow">Living story view</p>
|
||||||
|
<h1 id="living-story-title" data-scene-title>Reading the manuscript</h1>
|
||||||
|
<p data-scene-summary></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="story-exp-visual" data-visual-stage>
|
||||||
|
<svg class="story-exp-connections" data-connections aria-hidden="true"></svg>
|
||||||
|
<div class="story-exp-zone story-exp-zone--characters" aria-label="Characters">
|
||||||
|
<p class="story-exp-zone-label">Characters</p>
|
||||||
|
<div class="story-exp-characters" data-characters></div>
|
||||||
|
</div>
|
||||||
|
<div class="story-exp-zone story-exp-zone--location" aria-label="Active location">
|
||||||
|
<p class="story-exp-zone-label">Location</p>
|
||||||
|
<div class="story-exp-location" data-location></div>
|
||||||
|
</div>
|
||||||
|
<div class="story-exp-zone story-exp-zone--assets" aria-label="Significant assets">
|
||||||
|
<p class="story-exp-zone-label">Assets</p>
|
||||||
|
<div class="story-exp-assets" data-assets></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="story-exp-ribbon-wrap" aria-label="Recent scenes and timeline">
|
||||||
|
<div class="story-exp-ribbon" data-scene-ribbon></div>
|
||||||
|
<div class="story-exp-timeline" data-timeline></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="story-exp-right" aria-label="Story understanding">
|
||||||
|
<section class="story-exp-panel" aria-labelledby="relationships-title">
|
||||||
|
<h2 id="relationships-title">Relationship changes</h2>
|
||||||
|
<div class="story-exp-insight-list" data-relationships></div>
|
||||||
|
</section>
|
||||||
|
<section class="story-exp-panel" aria-labelledby="knowledge-title">
|
||||||
|
<h2 id="knowledge-title">Knowledge threads</h2>
|
||||||
|
<div class="story-exp-insight-list" data-knowledge></div>
|
||||||
|
</section>
|
||||||
|
<section class="story-exp-panel" aria-labelledby="observations-title">
|
||||||
|
<h2 id="observations-title">Story observations</h2>
|
||||||
|
<div class="story-exp-insight-list" data-observations></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="story-exp-controls" aria-label="Prototype controls">
|
||||||
|
<div>
|
||||||
|
<span>Prototype controls</span>
|
||||||
|
<small>Simulation only</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-control="restart">Restart</button>
|
||||||
|
<button type="button" data-control="previous">Previous</button>
|
||||||
|
<button type="button" data-control="play">Pause</button>
|
||||||
|
<button type="button" data-control="next">Next</button>
|
||||||
|
<label>
|
||||||
|
Speed
|
||||||
|
<input type="range" min="3500" max="10000" step="500" value="6200" data-speed-control />
|
||||||
|
</label>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="~/js/story-intelligence-experience-prototype.js" asp-append-version="true"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1074
PlotLine/wwwroot/css/story-intelligence-experience-prototype.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
|
||||||
|
<rect width="220" height="220" rx="24" fill="#102035"/>
|
||||||
|
<circle cx="84" cy="84" r="34" fill="none" stroke="#d7a85d" stroke-width="12"/>
|
||||||
|
<path d="M108 108l62 62M142 142l18-18M158 158l18-18" stroke="#d7a85d" stroke-width="13" stroke-linecap="round"/>
|
||||||
|
<path d="M55 150c24 20 61 25 94 12" stroke="#8fb4d4" stroke-width="5" fill="none" opacity=".55"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 425 B |
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
|
||||||
|
<rect width="220" height="220" rx="24" fill="#102035"/>
|
||||||
|
<path d="M42 70h136v90H42z" fill="#f0d8a8" stroke="#c49b5b" stroke-width="5"/>
|
||||||
|
<path d="M42 72l68 54 68-54M42 160l54-48M178 160l-54-48" fill="none" stroke="#9d7440" stroke-width="5"/>
|
||||||
|
<circle cx="110" cy="126" r="14" fill="#9c2f2f"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 368 B |
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
|
||||||
|
<rect width="220" height="220" rx="24" fill="#102035"/>
|
||||||
|
<rect x="62" y="36" width="104" height="148" rx="8" fill="#274257" stroke="#b9c9d6" stroke-width="5"/>
|
||||||
|
<path d="M82 66h58M82 92h58M82 118h46M82 144h52" stroke="#d7a85d" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
<path d="M62 58h-16M62 90h-16M62 122h-16M62 154h-16" stroke="#d7e3ee" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 457 B |
@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
|
||||||
|
<rect width="220" height="220" rx="24" fill="#102035"/>
|
||||||
|
<rect x="70" y="34" width="80" height="152" rx="16" fill="#111820" stroke="#8fb4d4" stroke-width="6"/>
|
||||||
|
<rect x="82" y="58" width="56" height="92" rx="6" fill="#24445e"/>
|
||||||
|
<circle cx="110" cy="166" r="7" fill="#d7a85d"/>
|
||||||
|
<path d="M92 92c12-11 24-11 36 0M84 78c18-18 34-18 52 0" stroke="#d7a85d" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 483 B |
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
|
||||||
|
<rect width="220" height="220" rx="24" fill="#102035"/>
|
||||||
|
<path d="M38 118h144l18 24v28H20v-30z" fill="#9f2525"/>
|
||||||
|
<path d="M68 88h78l28 30H44z" fill="#be3a32"/>
|
||||||
|
<rect x="78" y="94" width="34" height="24" fill="#dce9f4" opacity=".75"/>
|
||||||
|
<rect x="118" y="94" width="28" height="24" fill="#dce9f4" opacity=".55"/>
|
||||||
|
<circle cx="58" cy="170" r="20" fill="#050914" stroke="#aabdd0" stroke-width="6"/>
|
||||||
|
<circle cx="162" cy="170" r="20" fill="#050914" stroke="#aabdd0" stroke-width="6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 559 B |
@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
|
||||||
|
<rect width="220" height="220" rx="24" fill="#102035"/>
|
||||||
|
<path d="M76 80c2-35 66-35 68 0" fill="none" stroke="#d7a85d" stroke-width="9"/>
|
||||||
|
<rect x="58" y="70" width="104" height="112" rx="20" fill="#33495d" stroke="#8aa8bf" stroke-width="5"/>
|
||||||
|
<rect x="78" y="118" width="64" height="42" rx="10" fill="#1c2c3a"/>
|
||||||
|
<path d="M80 94h60" stroke="#d7a85d" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 469 B |
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
|
||||||
|
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#183a5a"/><stop offset="1" stop-color="#c28b58"/></linearGradient></defs>
|
||||||
|
<rect width="300" height="300" fill="url(#g)"/>
|
||||||
|
<circle cx="150" cy="118" r="62" fill="#f0c7a4"/>
|
||||||
|
<path d="M82 132c5-70 41-94 85-82 34 9 52 36 51 82-28-30-76-34-136 0z" fill="#3b251d"/>
|
||||||
|
<path d="M58 300c14-74 50-111 92-111s78 37 92 111z" fill="#1b2638"/>
|
||||||
|
<path d="M110 157c23 22 57 22 80 0" fill="none" stroke="#8f5b48" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<circle cx="126" cy="120" r="6" fill="#1c2530"/><circle cx="174" cy="120" r="6" fill="#1c2530"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 692 B |
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
|
||||||
|
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#1d2330"/><stop offset="1" stop-color="#714033"/></linearGradient></defs>
|
||||||
|
<rect width="300" height="300" fill="url(#g)"/>
|
||||||
|
<circle cx="150" cy="116" r="59" fill="#d7ad91"/>
|
||||||
|
<path d="M91 93c12-39 41-58 76-50 27 6 47 23 54 52-42-15-84-16-130-2z" fill="#2b2423"/>
|
||||||
|
<path d="M58 300c15-72 50-108 92-108s77 36 92 108z" fill="#2a2f38"/>
|
||||||
|
<path d="M119 158c18 5 44 5 62 0" fill="none" stroke="#6f463e" stroke-width="7" stroke-linecap="round"/>
|
||||||
|
<circle cx="126" cy="120" r="6" fill="#111820"/><circle cx="174" cy="120" r="6" fill="#111820"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 690 B |
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
|
||||||
|
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#11243b"/><stop offset="1" stop-color="#755a8c"/></linearGradient></defs>
|
||||||
|
<rect width="300" height="300" fill="url(#g)"/>
|
||||||
|
<circle cx="150" cy="120" r="60" fill="#e8bea3"/>
|
||||||
|
<path d="M78 126c-2-55 26-92 72-92s74 37 72 92c-23-38-49-52-72-52s-49 14-72 52z" fill="#d8d1c3"/>
|
||||||
|
<path d="M62 300c17-70 52-105 88-105s71 35 88 105z" fill="#473a56"/>
|
||||||
|
<path d="M118 157c19 16 45 16 64 0" fill="none" stroke="#8d5b50" stroke-width="7" stroke-linecap="round"/>
|
||||||
|
<circle cx="127" cy="121" r="5" fill="#28313d"/><circle cx="173" cy="121" r="5" fill="#28313d"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 702 B |
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
|
||||||
|
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#163d48"/><stop offset="1" stop-color="#b45d75"/></linearGradient></defs>
|
||||||
|
<rect width="300" height="300" fill="url(#g)"/>
|
||||||
|
<circle cx="150" cy="120" r="58" fill="#f1c9a8"/>
|
||||||
|
<path d="M84 118c9-52 42-80 80-70 33 9 54 36 55 75-46-28-90-29-135-5z" fill="#8a4f35"/>
|
||||||
|
<path d="M66 300c19-68 51-103 84-103s65 35 84 103z" fill="#1d4f58"/>
|
||||||
|
<path d="M119 158c19 15 43 15 62 0" fill="none" stroke="#8f5b48" stroke-width="7" stroke-linecap="round"/>
|
||||||
|
<circle cx="127" cy="121" r="5" fill="#1c2530"/><circle cx="173" cy="121" r="5" fill="#1c2530"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 692 B |
@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 520">
|
||||||
|
<rect width="420" height="520" fill="#07111f"/>
|
||||||
|
<path d="M0 365c120-50 300-50 420 0v155H0z" fill="#1a2430"/>
|
||||||
|
<path d="M70 376h280" stroke="#d7a85d" stroke-width="7" stroke-dasharray="28 20" opacity=".7"/>
|
||||||
|
<path d="M82 300h252l48 52v46H42v-48z" fill="#8e2424"/>
|
||||||
|
<circle cx="114" cy="398" r="34" fill="#050914" stroke="#9ab3c7" stroke-width="7"/>
|
||||||
|
<circle cx="310" cy="398" r="34" fill="#050914" stroke="#9ab3c7" stroke-width="7"/>
|
||||||
|
<rect x="142" y="314" width="92" height="40" fill="#dce9f4" opacity=".7"/>
|
||||||
|
<rect x="250" y="316" width="62" height="38" fill="#dce9f4" opacity=".55"/>
|
||||||
|
<circle cx="58" cy="86" r="34" fill="#d7a85d" opacity=".35"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 730 B |
@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 520">
|
||||||
|
<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#10243d"/><stop offset="1" stop-color="#050914"/></linearGradient></defs>
|
||||||
|
<rect width="420" height="520" fill="url(#sky)"/>
|
||||||
|
<path d="M45 470h330V210L210 95 45 210z" fill="#1b2b3a" stroke="#596f84" stroke-width="5"/>
|
||||||
|
<path d="M80 220h260v250H80z" fill="#243648"/>
|
||||||
|
<path d="M138 470V330h72v140z" fill="#111923"/>
|
||||||
|
<g fill="#d5a65a"><rect x="104" y="250" width="48" height="58"/><rect x="268" y="250" width="48" height="58"/><rect x="218" y="350" width="52" height="64"/></g>
|
||||||
|
<path d="M0 472c95-31 190-31 420 0v48H0z" fill="#07111f"/>
|
||||||
|
<g stroke="#8fb4d4" stroke-width="2" opacity=".35"><path d="M40 30v90"/><path d="M95 10v100"/><path d="M340 22v120"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 816 B |
@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 520">
|
||||||
|
<rect width="420" height="520" fill="#101a2a"/>
|
||||||
|
<rect x="46" y="66" width="328" height="388" rx="18" fill="#29384a"/>
|
||||||
|
<rect x="74" y="96" width="138" height="110" fill="#c79b5a"/>
|
||||||
|
<rect x="234" y="96" width="110" height="230" fill="#172130"/>
|
||||||
|
<rect x="94" y="250" width="112" height="56" fill="#5e4234"/>
|
||||||
|
<rect x="103" y="230" width="84" height="28" fill="#f0d2a5"/>
|
||||||
|
<path d="M72 398c75-36 175-34 276 0" fill="none" stroke="#d7a85d" stroke-width="5" opacity=".45"/>
|
||||||
|
<circle cx="296" cy="388" r="34" fill="#d7a85d" opacity=".28"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 616 B |
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 520">
|
||||||
|
<rect width="420" height="520" fill="#091522"/>
|
||||||
|
<rect x="50" y="82" width="320" height="354" rx="16" fill="#1c3341"/>
|
||||||
|
<circle cx="146" cy="286" r="64" fill="#0e1b25" stroke="#9db7c9" stroke-width="8"/>
|
||||||
|
<circle cx="146" cy="286" r="34" fill="#5e8fa0" opacity=".65"/>
|
||||||
|
<rect x="232" y="222" width="86" height="130" rx="8" fill="#d7a85d" opacity=".72"/>
|
||||||
|
<path d="M80 148h260" stroke="#d8e7f2" stroke-width="5" opacity=".35"/>
|
||||||
|
<path d="M96 164c42 24 86 24 132 0" stroke="#f2d8a4" stroke-width="6" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 590 B |
@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 520">
|
||||||
|
<rect width="420" height="520" fill="#07111f"/>
|
||||||
|
<path d="M64 90h292v360H64z" fill="#162333"/>
|
||||||
|
<path d="M86 424h248M108 380h226M130 336h204M152 292h182M174 248h160" stroke="#8da8bf" stroke-width="18" opacity=".55"/>
|
||||||
|
<path d="M246 80v390" stroke="#d7a85d" stroke-width="8" opacity=".7"/>
|
||||||
|
<rect x="270" y="112" width="54" height="92" fill="#c9904f" opacity=".68"/>
|
||||||
|
<path d="M0 520L420 80v440z" fill="#000" opacity=".22"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 501 B |
627
PlotLine/wwwroot/js/story-intelligence-experience-prototype.js
Normal file
@ -0,0 +1,627 @@
|
|||||||
|
(() => {
|
||||||
|
const root = document.querySelector("[data-story-experience]");
|
||||||
|
const dataNode = document.getElementById("storyExperienceData");
|
||||||
|
if (!root || !dataNode) return;
|
||||||
|
|
||||||
|
const model = JSON.parse(dataNode.textContent || "{}");
|
||||||
|
const scenes = model.scenes || [];
|
||||||
|
if (!scenes.length) return;
|
||||||
|
|
||||||
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const state = {
|
||||||
|
index: 0,
|
||||||
|
playing: true,
|
||||||
|
intervalMs: 6200,
|
||||||
|
timer: null
|
||||||
|
};
|
||||||
|
const analysisStages = [
|
||||||
|
{ key: "reading", label: "Reading" },
|
||||||
|
{ key: "chapters", label: "Chapters" },
|
||||||
|
{ key: "scenes", label: "Scenes" },
|
||||||
|
{ key: "content", label: "Content" },
|
||||||
|
{ key: "characters", label: "Characters" },
|
||||||
|
{ key: "locations", label: "Locations" },
|
||||||
|
{ key: "assets", label: "Assets" },
|
||||||
|
{ key: "relationships", label: "Relationships" },
|
||||||
|
{ key: "knowledge", label: "Knowledge" },
|
||||||
|
{ key: "continuity", label: "Continuity" },
|
||||||
|
{ key: "finalising", label: "Finalising" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const dom = {
|
||||||
|
stageLabel: root.querySelector("[data-stage-label]"),
|
||||||
|
stageRail: root.querySelector("[data-analysis-stages]"),
|
||||||
|
currentScene: root.querySelector("[data-current-scene]"),
|
||||||
|
progressPercent: root.querySelector("[data-progress-percent]"),
|
||||||
|
elapsedTime: root.querySelector("[data-elapsed-time]"),
|
||||||
|
estimatedRemaining: root.querySelector("[data-estimated-remaining]"),
|
||||||
|
progressRing: root.querySelector(".story-exp-progress-ring"),
|
||||||
|
chapterCount: root.querySelector("[data-chapter-count]"),
|
||||||
|
sceneCount: root.querySelector("[data-scene-count]"),
|
||||||
|
currentStage: root.querySelector("[data-current-stage]"),
|
||||||
|
discoveries: root.querySelector("[data-discoveries]"),
|
||||||
|
activityFeed: root.querySelector("[data-activity-feed]"),
|
||||||
|
sceneTitle: root.querySelector("[data-scene-title]"),
|
||||||
|
sceneSummary: root.querySelector("[data-scene-summary]"),
|
||||||
|
visualStage: root.querySelector("[data-visual-stage]"),
|
||||||
|
connections: root.querySelector("[data-connections]"),
|
||||||
|
characters: root.querySelector("[data-characters]"),
|
||||||
|
location: root.querySelector("[data-location]"),
|
||||||
|
assets: root.querySelector("[data-assets]"),
|
||||||
|
ribbon: root.querySelector("[data-scene-ribbon]"),
|
||||||
|
timeline: root.querySelector("[data-timeline]"),
|
||||||
|
relationships: root.querySelector("[data-relationships]"),
|
||||||
|
knowledge: root.querySelector("[data-knowledge]"),
|
||||||
|
observations: root.querySelector("[data-observations]"),
|
||||||
|
play: root.querySelector("[data-control='play']"),
|
||||||
|
speed: root.querySelector("[data-speed-control]")
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadSceneState(index) {
|
||||||
|
return scenes[Math.max(0, Math.min(scenes.length - 1, index))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitionToNextState(nextIndex) {
|
||||||
|
const previous = loadSceneState(state.index);
|
||||||
|
state.index = Math.max(0, Math.min(scenes.length - 1, nextIndex));
|
||||||
|
const current = loadSceneState(state.index);
|
||||||
|
|
||||||
|
updateProgress(current);
|
||||||
|
updateAnalysisStages(current);
|
||||||
|
updateSceneCopy(current);
|
||||||
|
reconcileCharacters(current, previous);
|
||||||
|
reconcileLocation(current, previous);
|
||||||
|
reconcileAssets(current, previous);
|
||||||
|
reconcileRelationships(current);
|
||||||
|
updateKnowledgeThreads(current);
|
||||||
|
updateSceneRibbon(current);
|
||||||
|
updateTimeline(current);
|
||||||
|
updateFeeds(current);
|
||||||
|
scheduleConnectionDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress(scene) {
|
||||||
|
text(dom.stageLabel, scene.stage);
|
||||||
|
text(dom.currentScene, scene.sceneNumber);
|
||||||
|
text(dom.progressPercent, `${scene.progressPercent}%`);
|
||||||
|
text(dom.elapsedTime, `${Math.max(1, Math.round(scene.progressPercent / 9))}m`);
|
||||||
|
text(dom.estimatedRemaining, scene.estimatedRemaining);
|
||||||
|
text(dom.chapterCount, `${scene.chaptersAnalysed} of ${scene.chapterTotal}`);
|
||||||
|
text(dom.sceneCount, `${scene.scenesAnalysed} of ${scene.sceneTotal}`);
|
||||||
|
text(dom.currentStage, scene.stage);
|
||||||
|
if (dom.progressRing) dom.progressRing.style.setProperty("--progress-value", `${scene.progressPercent * 3.6}deg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAnalysisStages(scene) {
|
||||||
|
if (!dom.stageRail) return;
|
||||||
|
const currentIndex = stageIndex(scene);
|
||||||
|
dom.stageRail.innerHTML = "";
|
||||||
|
analysisStages.forEach((stage, index) => {
|
||||||
|
const node = document.createElement("li");
|
||||||
|
node.className = "story-exp-pipeline-step";
|
||||||
|
node.classList.toggle("is-complete", index < currentIndex || scene.progressPercent >= 100);
|
||||||
|
node.classList.toggle("is-current", index === currentIndex && scene.progressPercent < 100);
|
||||||
|
node.classList.toggle("is-future", index > currentIndex && scene.progressPercent < 100);
|
||||||
|
node.innerHTML = "<span></span><strong></strong>";
|
||||||
|
text(node.querySelector("strong"), stage.label);
|
||||||
|
dom.stageRail.append(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageIndex(scene) {
|
||||||
|
const stage = String(scene.stage || "").toLowerCase();
|
||||||
|
if (scene.progressPercent >= 100 || stage.includes("review")) return analysisStages.length - 1;
|
||||||
|
if (stage.includes("continuity")) return 9;
|
||||||
|
if (stage.includes("knowledge")) return 8;
|
||||||
|
if (stage.includes("relationship")) return 7;
|
||||||
|
if (stage.includes("asset")) return 6;
|
||||||
|
if (stage.includes("location")) return 5;
|
||||||
|
if (stage.includes("character")) return 4;
|
||||||
|
if (stage.includes("content") || stage.includes("inferring")) return 3;
|
||||||
|
if (stage.includes("scene")) return 2;
|
||||||
|
if (stage.includes("chapter")) return 1;
|
||||||
|
return Math.max(0, Math.min(analysisStages.length - 1, Math.floor(scene.progressPercent / 10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSceneCopy(scene) {
|
||||||
|
text(dom.sceneTitle, scene.title);
|
||||||
|
text(dom.sceneSummary, scene.summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileCharacters(scene) {
|
||||||
|
const activeIds = new Set(scene.characters.map((character) => character.id));
|
||||||
|
scene.characters.forEach((character, index) => {
|
||||||
|
let node = dom.characters.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
|
||||||
|
if (!node) {
|
||||||
|
node = createCharacterNode(character);
|
||||||
|
dom.characters.append(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = characterPosition(character, index, scene.povCharacterId);
|
||||||
|
node.classList.toggle("is-pov", character.id === scene.povCharacterId);
|
||||||
|
node.style.setProperty("--node-size", `${position.size}px`);
|
||||||
|
node.style.setProperty("--x", `${position.x}px`);
|
||||||
|
node.style.setProperty("--y", `${position.y}px`);
|
||||||
|
node.style.setProperty("--scale", String(position.scale));
|
||||||
|
node.style.setProperty("--opacity", String(position.opacity));
|
||||||
|
node.querySelector("img").src = character.imagePath;
|
||||||
|
node.querySelector("img").alt = `${character.name} prototype portrait`;
|
||||||
|
text(node.querySelector("[data-character-name]"), character.name);
|
||||||
|
text(node.querySelector("[data-character-role]"), `${character.role} - ${character.relevance}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.characters.querySelectorAll("[data-character-id]").forEach((node) => {
|
||||||
|
if (!activeIds.has(node.dataset.characterId)) {
|
||||||
|
node.style.setProperty("--opacity", "0");
|
||||||
|
node.style.setProperty("--scale", "0.86");
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const currentIds = new Set(loadSceneState(state.index).characters.map((character) => character.id));
|
||||||
|
if (!currentIds.has(node.dataset.characterId)) node.remove();
|
||||||
|
}, reducedMotion ? 0 : 620);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileLocation(scene, previous) {
|
||||||
|
const changed = previous?.location?.id !== scene.location.id;
|
||||||
|
if (changed) {
|
||||||
|
dom.location.firstElementChild?.classList.add("is-changing");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
dom.location.innerHTML = "";
|
||||||
|
const node = document.createElement("article");
|
||||||
|
node.className = "story-location";
|
||||||
|
node.dataset.locationId = scene.location.id;
|
||||||
|
node.innerHTML = `
|
||||||
|
<div class="story-location__image"><img alt="${escapeHtml(scene.location.name)} prototype location" /></div>
|
||||||
|
<div class="story-location__text">
|
||||||
|
<h2></h2>
|
||||||
|
<p></p>
|
||||||
|
</div>`;
|
||||||
|
const position = locationPosition();
|
||||||
|
node.style.setProperty("--node-size", `${position.size}px`);
|
||||||
|
node.style.setProperty("--x", `${position.x}px`);
|
||||||
|
node.style.setProperty("--y", `${position.y}px`);
|
||||||
|
node.querySelector("img").src = scene.location.imagePath;
|
||||||
|
text(node.querySelector("h2"), scene.location.name);
|
||||||
|
text(node.querySelector("p"), scene.location.label);
|
||||||
|
dom.location.append(node);
|
||||||
|
scheduleConnectionDraw();
|
||||||
|
}, changed && !reducedMotion ? 260 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileAssets(scene) {
|
||||||
|
const activeIds = new Set(scene.assets.map((asset) => asset.id));
|
||||||
|
scene.assets.forEach((asset, index) => {
|
||||||
|
let node = dom.assets.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
|
||||||
|
if (!node) {
|
||||||
|
node = createAssetNode(asset);
|
||||||
|
dom.assets.append(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = assetPosition(asset, index, scene.assets.length);
|
||||||
|
node.style.setProperty("--node-size", `${position.size}px`);
|
||||||
|
node.style.setProperty("--x", `${position.x}px`);
|
||||||
|
node.style.setProperty("--y", `${position.y}px`);
|
||||||
|
node.style.setProperty("--scale", String(position.scale));
|
||||||
|
node.style.setProperty("--opacity", "1");
|
||||||
|
node.querySelector("img").src = asset.imagePath;
|
||||||
|
node.querySelector("img").alt = `${asset.name} prototype asset`;
|
||||||
|
text(node.querySelector("[data-asset-name]"), asset.name);
|
||||||
|
text(node.querySelector("[data-asset-label]"), asset.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.assets.querySelectorAll("[data-asset-id]").forEach((node) => {
|
||||||
|
if (!activeIds.has(node.dataset.assetId)) {
|
||||||
|
node.style.setProperty("--opacity", "0");
|
||||||
|
node.style.setProperty("--scale", "0.8");
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const currentIds = new Set(loadSceneState(state.index).assets.map((asset) => asset.id));
|
||||||
|
if (!currentIds.has(node.dataset.assetId)) node.remove();
|
||||||
|
}, reducedMotion ? 0 : 520);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileRelationships(scene) {
|
||||||
|
renderInsights(dom.relationships, scene.relationships.map((relationship) => ({
|
||||||
|
id: relationship.id,
|
||||||
|
title: relationship.label,
|
||||||
|
detail: relationship.state,
|
||||||
|
tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging"
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKnowledgeThreads(scene) {
|
||||||
|
renderInsights(dom.knowledge, scene.knowledgeThreads);
|
||||||
|
renderInsights(dom.observations, scene.observations);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSceneRibbon(scene) {
|
||||||
|
const currentIndex = state.index;
|
||||||
|
const start = Math.max(0, Math.min(scenes.length - 7, currentIndex - 3));
|
||||||
|
const visible = scenes.slice(start, start + 7);
|
||||||
|
dom.ribbon.innerHTML = "";
|
||||||
|
visible.forEach((item) => {
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = "story-scene-card";
|
||||||
|
card.classList.toggle("is-current", item.id === scene.id);
|
||||||
|
card.innerHTML = `<span>Scene ${item.sceneNumber}</span><strong></strong><small></small>`;
|
||||||
|
text(card.querySelector("strong"), item.title);
|
||||||
|
text(card.querySelector("small"), item.timelineEvent);
|
||||||
|
dom.ribbon.append(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimeline(scene) {
|
||||||
|
const completed = scenes.slice(0, state.index + 1);
|
||||||
|
dom.timeline.style.setProperty("--timeline-count", String(completed.length));
|
||||||
|
dom.timeline.innerHTML = "";
|
||||||
|
completed.forEach((item) => {
|
||||||
|
const marker = document.createElement("div");
|
||||||
|
marker.className = "story-timeline-marker";
|
||||||
|
marker.classList.toggle("is-current", item.id === scene.id);
|
||||||
|
marker.innerHTML = `<span></span>`;
|
||||||
|
text(marker.querySelector("span"), `${item.timelineLabel} - S${item.sceneNumber}`);
|
||||||
|
dom.timeline.append(marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFeeds(scene) {
|
||||||
|
renderList(dom.discoveries, scene.latestDiscoveries);
|
||||||
|
renderList(dom.activityFeed, scene.activityFeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInsights(container, items) {
|
||||||
|
const activeIds = new Set(items.map((item) => item.id));
|
||||||
|
items.forEach((item) => {
|
||||||
|
let node = container.querySelector(`[data-insight-id="${cssEscape(item.id)}"]`);
|
||||||
|
if (!node) {
|
||||||
|
node = document.createElement("article");
|
||||||
|
node.className = "story-exp-insight";
|
||||||
|
node.dataset.insightId = item.id;
|
||||||
|
node.innerHTML = `<span></span><strong></strong><p></p>`;
|
||||||
|
container.append(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.dataset.tone = item.tone || "";
|
||||||
|
text(node.querySelector("span"), item.tone || "Signal");
|
||||||
|
text(node.querySelector("strong"), item.title);
|
||||||
|
text(node.querySelector("p"), item.detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll("[data-insight-id]").forEach((node) => {
|
||||||
|
if (!activeIds.has(node.dataset.insightId)) {
|
||||||
|
node.style.opacity = "0";
|
||||||
|
node.style.transform = "translateY(8px)";
|
||||||
|
window.setTimeout(() => node.remove(), reducedMotion ? 0 : 360);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(container, items) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
items.forEach((item) => {
|
||||||
|
const node = document.createElement(container.tagName === "OL" ? "li" : "li");
|
||||||
|
if (container === dom.discoveries) {
|
||||||
|
node.dataset.discoveryType = discoveryType(item);
|
||||||
|
node.innerHTML = "<span aria-hidden=\"true\"></span><p></p>";
|
||||||
|
text(node.querySelector("p"), item);
|
||||||
|
} else {
|
||||||
|
node.textContent = item;
|
||||||
|
}
|
||||||
|
container.append(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoveryType(item) {
|
||||||
|
const value = String(item || "").toLowerCase();
|
||||||
|
if (value.includes("relationship") || value.includes("trust") || value.includes("conflict")) return "relationship";
|
||||||
|
if (value.includes("knowledge") || value.includes("thread") || value.includes("secret")) return "knowledge";
|
||||||
|
if (value.includes("location") || value.includes("doweries") || value.includes("flat") || value.includes("stairwell")) return "location";
|
||||||
|
if (value.includes("letter") || value.includes("notebook") || value.includes("rucksack") || value.includes("tr6") || value.includes("keys") || value.includes("phone")) return "asset";
|
||||||
|
return "character";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleConnectionDraw() {
|
||||||
|
window.requestAnimationFrame(() => drawConnections(loadSceneState(state.index)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawConnections(scene) {
|
||||||
|
if (!dom.connections || !dom.visualStage) return;
|
||||||
|
const stageRect = dom.visualStage.getBoundingClientRect();
|
||||||
|
dom.connections.innerHTML = "";
|
||||||
|
const drawn = [];
|
||||||
|
|
||||||
|
scene.relationships.forEach((relationship, index) => {
|
||||||
|
const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`);
|
||||||
|
const target = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.targetId)}"]`);
|
||||||
|
if (!source || !target) return;
|
||||||
|
|
||||||
|
const start = connectionPoint(source, stageRect, target);
|
||||||
|
const end = connectionPoint(target, stageRect, source);
|
||||||
|
drawn.push(drawConnection(start, end, relationshipTone(relationship), relationship.weight, index === 0 ? inlineRelationshipLabel(relationship) : ""));
|
||||||
|
});
|
||||||
|
|
||||||
|
const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`);
|
||||||
|
const location = dom.visualStage.querySelector("[data-location-id]");
|
||||||
|
if (pov && location) {
|
||||||
|
drawn.push(drawConnection(connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 72, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.characters
|
||||||
|
.filter((character) => character.id !== scene.povCharacterId && character.weight >= 65)
|
||||||
|
.slice(0, 2)
|
||||||
|
.forEach((character) => {
|
||||||
|
const node = dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
|
||||||
|
if (node && location) {
|
||||||
|
drawn.push(drawConnection(connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, ""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
scene.assets.forEach((asset, index) => {
|
||||||
|
const assetNode = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
|
||||||
|
if (!assetNode || !location) return;
|
||||||
|
drawn.push(drawConnection(connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : ""));
|
||||||
|
if (pov && index < 2) {
|
||||||
|
drawn.push(drawConnection(connectionPoint(pov, stageRect, assetNode), connectionPoint(assetNode, stageRect, pov), "knowledge", Math.max(45, asset.weight - 18), ""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scene.assets.length > 1) {
|
||||||
|
const first = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(scene.assets[0].id)}"]`);
|
||||||
|
const second = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(scene.assets[1].id)}"]`);
|
||||||
|
if (first && second) {
|
||||||
|
drawn.push(drawConnection(connectionPoint(first, stageRect, second), connectionPoint(second, stageRect, first), "asset-link", 48, ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawConnection(start, end, tone, weight, label) {
|
||||||
|
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||||
|
group.setAttribute("class", `story-exp-connection-group story-exp-connection-group--${tone}`);
|
||||||
|
|
||||||
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
const dx = end.x - start.x;
|
||||||
|
const dy = end.y - start.y;
|
||||||
|
const curve = Math.max(34, Math.min(126, Math.abs(dx) * 0.24 + Math.abs(dy) * 0.1));
|
||||||
|
const bend = start.x < end.x ? curve : -curve;
|
||||||
|
const d = `M ${start.x} ${start.y} C ${start.x + bend} ${start.y - curve * 0.28}, ${end.x - bend} ${end.y + curve * 0.28}, ${end.x} ${end.y}`;
|
||||||
|
path.setAttribute("d", d);
|
||||||
|
path.setAttribute("pathLength", "1");
|
||||||
|
path.setAttribute("class", "story-exp-connection");
|
||||||
|
path.style.strokeWidth = String(1.1 + Math.min(weight, 100) / 56);
|
||||||
|
group.append(path);
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||||
|
labelNode.setAttribute("class", "story-exp-connection-label");
|
||||||
|
labelNode.setAttribute("x", String((start.x + end.x) / 2));
|
||||||
|
labelNode.setAttribute("y", String((start.y + end.y) / 2 - 8));
|
||||||
|
labelNode.setAttribute("text-anchor", "middle");
|
||||||
|
labelNode.textContent = label;
|
||||||
|
group.append(labelNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.connections.append(group);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCharacterNode(character) {
|
||||||
|
const node = document.createElement("article");
|
||||||
|
node.className = "story-character";
|
||||||
|
node.dataset.characterId = character.id;
|
||||||
|
node.innerHTML = `
|
||||||
|
<div class="story-character__image"><img loading="eager" /></div>
|
||||||
|
<div class="story-character__text">
|
||||||
|
<strong data-character-name></strong>
|
||||||
|
<span data-character-role></span>
|
||||||
|
</div>`;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAssetNode(asset) {
|
||||||
|
const node = document.createElement("article");
|
||||||
|
node.className = "story-asset";
|
||||||
|
node.dataset.assetId = asset.id;
|
||||||
|
node.innerHTML = `
|
||||||
|
<div class="story-asset__image"><img loading="eager" /></div>
|
||||||
|
<div class="story-asset__text">
|
||||||
|
<strong data-asset-name></strong>
|
||||||
|
<span data-asset-label></span>
|
||||||
|
</div>`;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function characterPosition(character, index, povId) {
|
||||||
|
const bounds = dom.visualStage?.getBoundingClientRect();
|
||||||
|
const width = Math.max(820, bounds?.width || 1040);
|
||||||
|
const height = Math.max(500, bounds?.height || 560);
|
||||||
|
const povSize = Math.min(210, Math.max(166, width * 0.16));
|
||||||
|
|
||||||
|
if (character.id === povId) {
|
||||||
|
return {
|
||||||
|
x: Math.round(width * 0.25 - povSize / 2),
|
||||||
|
y: Math.round(height * 0.25 - povSize / 2),
|
||||||
|
size: Math.round(povSize),
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const important = character.weight >= 70;
|
||||||
|
const positions = important
|
||||||
|
? [
|
||||||
|
{ x: width * 0.12, y: height * 0.16 },
|
||||||
|
{ x: width * 0.38, y: height * 0.38 },
|
||||||
|
{ x: width * 0.16, y: height * 0.56 }
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ x: width * 0.08, y: height * 0.45 },
|
||||||
|
{ x: width * 0.39, y: height * 0.66 },
|
||||||
|
{ x: width * 0.18, y: height * 0.34 }
|
||||||
|
];
|
||||||
|
const slot = positions[index % positions.length];
|
||||||
|
const size = important
|
||||||
|
? Math.min(176, 136 + (character.weight - 70) * 0.9)
|
||||||
|
: Math.min(116, 88 + Math.max(0, character.weight - 24) * 0.45);
|
||||||
|
return {
|
||||||
|
x: Math.round(slot.x),
|
||||||
|
y: Math.round(slot.y),
|
||||||
|
size: Math.round(size),
|
||||||
|
scale: 1,
|
||||||
|
opacity: important ? 0.94 : 0.72
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationPosition() {
|
||||||
|
const bounds = dom.visualStage?.getBoundingClientRect();
|
||||||
|
const width = Math.max(820, bounds?.width || 1040);
|
||||||
|
const height = Math.max(500, bounds?.height || 560);
|
||||||
|
const size = Math.min(220, Math.max(176, width * 0.18));
|
||||||
|
return {
|
||||||
|
x: Math.round(width * 0.62 - size / 2),
|
||||||
|
y: Math.round(height * 0.46 - size / 2),
|
||||||
|
size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetPosition(asset, index, total) {
|
||||||
|
const bounds = dom.visualStage?.getBoundingClientRect();
|
||||||
|
const width = Math.max(820, bounds?.width || 1040);
|
||||||
|
const height = Math.max(500, bounds?.height || 560);
|
||||||
|
const anchors = [
|
||||||
|
{ x: 0.83, y: 0.26 },
|
||||||
|
{ x: 0.84, y: 0.51 },
|
||||||
|
{ x: 0.82, y: 0.74 },
|
||||||
|
{ x: 0.72, y: 0.19 }
|
||||||
|
];
|
||||||
|
const anchor = anchors[index % anchors.length];
|
||||||
|
const size = Math.min(116, Math.max(76, 64 + Math.min(asset.weight, 100) * 0.42));
|
||||||
|
return {
|
||||||
|
x: Math.round(width * anchor.x - size / 2),
|
||||||
|
y: Math.round(height * anchor.y - size / 2),
|
||||||
|
size: Math.round(size),
|
||||||
|
scale: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function centrePoint(node, parentRect) {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: rect.left - parentRect.left + rect.width / 2,
|
||||||
|
y: rect.top - parentRect.top + rect.height / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionPoint(node, parentRect, targetNode) {
|
||||||
|
const centre = centrePoint(node, parentRect);
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
const target = targetNode ? centrePoint(targetNode, parentRect) : centre;
|
||||||
|
const radius = Math.min(rect.width, rect.height) * 0.44;
|
||||||
|
const angle = Math.atan2(target.y - centre.y, target.x - centre.x);
|
||||||
|
return {
|
||||||
|
x: centre.x + Math.cos(angle) * radius,
|
||||||
|
y: centre.y + Math.sin(angle) * radius
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationshipTone(relationship) {
|
||||||
|
const stateText = `${relationship.label} ${relationship.state}`.toLowerCase();
|
||||||
|
if (stateText.includes("conflict") || stateText.includes("threat") || stateText.includes("obstruction") || stateText.includes("suspect")) return "conflict";
|
||||||
|
if (stateText.includes("trust") || stateText.includes("ally") || stateText.includes("protect")) return "support";
|
||||||
|
return relationship.weight >= 80 ? "strong" : "uncertain";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineRelationshipLabel(relationship) {
|
||||||
|
const stateText = `${relationship.label} ${relationship.state}`.toLowerCase();
|
||||||
|
if (stateText.includes("trust")) return "trust strengthened";
|
||||||
|
if (stateText.includes("ally") || stateText.includes("alliance")) return "ally";
|
||||||
|
if (stateText.includes("conflict") || stateText.includes("threat")) return "tension increasing";
|
||||||
|
if (stateText.includes("suspect") || stateText.includes("question")) return "questions";
|
||||||
|
if (stateText.includes("family") || stateText.includes("grief")) return "family";
|
||||||
|
if (stateText.includes("motive")) return "motive exposed";
|
||||||
|
return relationship.state || relationship.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetConnectionLabel(asset) {
|
||||||
|
const value = `${asset.name} ${asset.label}`.toLowerCase();
|
||||||
|
if (value.includes("letter")) return "evidence";
|
||||||
|
if (value.includes("key")) return "access";
|
||||||
|
if (value.includes("tr6") || value.includes("car")) return "blocks escape";
|
||||||
|
if (value.includes("phone")) return "recorded";
|
||||||
|
return "found here";
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
stopTimer();
|
||||||
|
if (!state.playing) return;
|
||||||
|
state.timer = window.setInterval(() => {
|
||||||
|
const next = state.index >= scenes.length - 1 ? 0 : state.index + 1;
|
||||||
|
transitionToNextState(next);
|
||||||
|
}, state.intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimer() {
|
||||||
|
if (state.timer) {
|
||||||
|
window.clearInterval(state.timer);
|
||||||
|
state.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(node, value) {
|
||||||
|
if (node) node.textContent = value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || "").replace(/[&<>"']/g, (match) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"\"": """,
|
||||||
|
"'": "'"
|
||||||
|
}[match]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssEscape(value) {
|
||||||
|
return window.CSS?.escape ? window.CSS.escape(value) : String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelector("[data-control='restart']")?.addEventListener("click", () => {
|
||||||
|
transitionToNextState(0);
|
||||||
|
startTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelector("[data-control='previous']")?.addEventListener("click", () => {
|
||||||
|
transitionToNextState(state.index <= 0 ? scenes.length - 1 : state.index - 1);
|
||||||
|
startTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelector("[data-control='next']")?.addEventListener("click", () => {
|
||||||
|
transitionToNextState(state.index >= scenes.length - 1 ? 0 : state.index + 1);
|
||||||
|
startTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.play?.addEventListener("click", () => {
|
||||||
|
state.playing = !state.playing;
|
||||||
|
text(dom.play, state.playing ? "Pause" : "Play");
|
||||||
|
if (state.playing) startTimer(); else stopTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.speed?.addEventListener("input", () => {
|
||||||
|
state.intervalMs = Number.parseInt(dom.speed.value, 10) || 6200;
|
||||||
|
startTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
const current = loadSceneState(state.index);
|
||||||
|
reconcileCharacters(current);
|
||||||
|
reconcileLocation(current, current);
|
||||||
|
reconcileAssets(current);
|
||||||
|
scheduleConnectionDraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
transitionToNextState(0);
|
||||||
|
startTimer();
|
||||||
|
})();
|
||||||
596
docs/phases/Phase-21A-Story-Intelligence-Visualisation-Audit.md
Normal file
@ -0,0 +1,596 @@
|
|||||||
|
# Phase 21A - Story Intelligence Visualisation Architecture Audit
|
||||||
|
|
||||||
|
## 1. Executive summary
|
||||||
|
|
||||||
|
PlotDirector already has most of the server-side ingredients needed for a durable Story Intelligence visualisation: ASP.NET Core MVC, SQL Server persistence, Dapper repositories, SignalR, a persisted background worker, canonical story entities, and a current progress page that survives browser and Word closure.
|
||||||
|
|
||||||
|
The current live experience is not yet graph-ready. SignalR messages are progress notifications, not durable events. Provisional discoveries are mostly reconstructed from saved scene-analysis JSON and transient in-memory batch decisions. Scene/chapter AI results have stable SQL identifiers, but provisional characters, locations, assets, relationships, knowledge items, confidence changes, merges and rejections do not have durable discovery records or ordered event history.
|
||||||
|
|
||||||
|
Sigma.js, Graphology and SignalR remain suitable for Phase 21 if SQL Server remains authoritative and SignalR is only a transport. The biggest prerequisite for Phase 21B is a durable visualisation snapshot/event model that can be rebuilt after refresh, replayed after reconnect, and joined to both provisional and canonical PlotDirector IDs.
|
||||||
|
|
||||||
|
No code, package, database, deployment or workflow changes were made for this audit.
|
||||||
|
|
||||||
|
## 2. Current Story Intelligence workflow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["Author starts onboarding import"] --> B["Website-led onboarding wizard"]
|
||||||
|
B --> C["Project and book selected or created"]
|
||||||
|
C --> D["Word Companion registers over SignalR"]
|
||||||
|
D --> E["Website asks Companion to scan document"]
|
||||||
|
E --> F["Companion reports scan preview"]
|
||||||
|
F --> G["Author reviews chapters, scenes, character candidates"]
|
||||||
|
G --> H["Onboarding builds approved chapter shells"]
|
||||||
|
H --> I["One StoryIntelligenceRun queued per approved chapter"]
|
||||||
|
I --> J["PersistedStoryIntelligenceWorker claims pending runs"]
|
||||||
|
J --> K["Chapter Structure prompt detects scene boundaries"]
|
||||||
|
K --> L["Scene Intelligence prompt analyses each suggested scene"]
|
||||||
|
L --> M["Chapter and scene results persisted in SQL"]
|
||||||
|
M --> N["SignalR progress updates current page"]
|
||||||
|
N --> O["Author reviews/imports scenes"]
|
||||||
|
O --> P["Characters, locations, assets, relationships, knowledge reviewed in sequence"]
|
||||||
|
P --> Q["Canonical PlotDirector records created or linked"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Important components:
|
||||||
|
|
||||||
|
| Component | Path | Responsibility | Current state |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `OnboardingController` | `PlotLine/Controllers/OnboardingController.cs` | Onboarding routes, scan review, Story Intelligence start/progress/review/import stages. | Current. Central workflow controller. |
|
||||||
|
| `WordCompanionFollowHub` | `PlotLine/Hubs/WordCompanionFollowHub.cs` | Companion presence, scan command, scan progress/completion, build marker updates. | Current. User-group scoped. |
|
||||||
|
| `ManuscriptScanPreviewStore` | `PlotLine/Services/ManuscriptScanPreviewStore.cs` | In-memory scan preview/review/build state. | Current but non-durable. Important recovery weakness. |
|
||||||
|
| `OnboardingService` | `PlotLine/Services/OnboardingService.cs` | Wizard state, scan review validation, approved chapter build. | Current. Creates chapter shells before AI run. |
|
||||||
|
| `OnboardingStoryIntelligenceService` | `PlotLine/Services/OnboardingStoryIntelligenceService.cs` | Queues per-chapter runs, builds progress/review models, orchestrates staged imports. | Current. Depends on in-memory batch store. |
|
||||||
|
| `OnboardingStoryIntelligenceBatchStore` | `PlotLine/Services/OnboardingStoryIntelligenceService.cs` | Keeps batch-to-run mapping and review decisions in a `ConcurrentDictionary`. | Current but volatile. Key gap. |
|
||||||
|
| `PersistedStoryIntelligenceWorker` | `PlotLine/Services/PersistedStoryIntelligenceWorker.cs` | Background service polling every 3 seconds for pending persisted runs. | Current. Browser-independent. |
|
||||||
|
| `PersistedStoryIntelligenceRunner` | `PlotLine/Services/PersistedStoryIntelligenceRunner.cs` | Executes chapter and scene prompts, validates, saves results, publishes progress. | Current. Per-run processing. |
|
||||||
|
| `StoryIntelligenceResultRepository` | `PlotLine/Data/StoryIntelligenceResultRepository.cs` | SQL access for runs, chapter/scene results, import commits. | Current. Core persistence path. |
|
||||||
|
| `StoryIntelligenceProgressNotifier` | `PlotLine/Services/StoryIntelligenceProgressNotifier.cs` | Broadcasts run progress to SignalR user group. | Current. Ephemeral messages. |
|
||||||
|
| `StoryIntelligenceImportCommitService` | `PlotLine/Services/StoryIntelligenceImportCommitService.cs` | Converts completed scene results into canonical chapters/scenes/metrics. | Current. Transaction is inside SQL proc. |
|
||||||
|
| Entity import services | `StoryIntelligenceCharacterImportService.cs`, `StoryIntelligenceLocationImportService.cs`, `StoryIntelligenceAssetImportService.cs`, `StoryIntelligenceRelationshipImportService.cs`, `StoryIntelligenceKnowledgeImportService.cs` | Build review candidates from committed scenes and saved AI JSON; create/link/ignore canonical entities. | Current. Decisions are partly transient. |
|
||||||
|
|
||||||
|
The older `StoryIntelligenceJobs` model still exists through `IStoryIntelligenceRepository`, `StoryIntelligenceService`, and legacy progress support, but the onboarding analysis flow now primarily uses `StoryIntelligenceRuns`.
|
||||||
|
|
||||||
|
## 3. Current background-processing architecture
|
||||||
|
|
||||||
|
`Program.cs` registers `StoryIntelligenceWorker`, `PersistedStoryIntelligenceWorker`, `EmailQueueWorker`, and `WordCompanionPresenceMonitor` as hosted services. The persisted runner claims SQL-backed pending runs via `StoryIntelligenceRun_ClaimNextPending` using `UPDLOCK, READPAST`, then processes a single run.
|
||||||
|
|
||||||
|
Reliability strengths:
|
||||||
|
|
||||||
|
- Processing is independent of the browser and Word once runs are queued.
|
||||||
|
- Runs persist status, stage, message, token totals, scene counts, failure stage, error text, cancellation timestamps, start/completion timestamps and source text.
|
||||||
|
- Cancellation is stored with `CancellationRequestedUtc` and checked by the runner.
|
||||||
|
- Failed chapter/scene JSON parse attempts can be persisted as result rows.
|
||||||
|
|
||||||
|
Weaknesses:
|
||||||
|
|
||||||
|
- No durable visualisation event stream exists.
|
||||||
|
- No explicit retry count or backoff model was found for failed Story Intelligence runs.
|
||||||
|
- No stale-running recovery was found for `StoryIntelligenceRuns` if the app dies after a run is claimed and marked `Running`.
|
||||||
|
- The active batch mapping from onboarding batch ID to run IDs is in memory only.
|
||||||
|
- Multiple app instances would need careful review: SQL claim locking helps workers, but in-memory batch state and SignalR user groups are process-local.
|
||||||
|
|
||||||
|
## 4. Current provisional discovery model
|
||||||
|
|
||||||
|
| Discovery type | Current storage before approval | Stable provisional ID? | Notes |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| Chapters | Scan preview/review in memory; approved chapter shells become canonical before analysis. | Temporary scan keys only. | Chapter shells are canonical before scene analysis starts. |
|
||||||
|
| Scenes | `StoryIntelligenceSceneResults` with `SceneResultID` and temporary scene number. | Yes for scene results. | Canonical scenes created during scene commit. |
|
||||||
|
| Characters | Parsed scene JSON and review candidates derived by service. | Derived key only. | Character decisions are in in-memory batch. |
|
||||||
|
| Character aliases | Derived/imported during character review. | No durable provisional alias ID. | Alias decisions not durable. |
|
||||||
|
| Locations | Parsed scene JSON and review candidates. | Derived key only. | Uses normalisation rules, no provisional table. |
|
||||||
|
| Assets | Parsed scene JSON and review candidates. | Derived key only. | Ownership/custody inferred during import service. |
|
||||||
|
| Relationships | Parsed scene JSON and review candidates. | Derived key only. | No persisted suspected/confirmed relationship lifecycle. |
|
||||||
|
| Timeline clues | Parsed scene JSON/import notes. | No. | Imported indirectly as scene notes/timeline data. |
|
||||||
|
| Knowledge | Parsed scene JSON and review candidates. | Derived key only. | Knowledge selections/decisions live in batch. |
|
||||||
|
| Confidence | Stored inside parsed AI JSON and some review previews. | Attached to JSON only. | No confidence history. |
|
||||||
|
| Evidence/excerpts | Source text and raw/parsed prompt output are retained. | Scene result IDs help. | User-facing evidence is not normalised. |
|
||||||
|
| Rejections | In-memory batch decisions while process lives. | No durable rejection ID. | Lost on application restart unless inferred from canonical state. |
|
||||||
|
| Duplicate/merge candidates | Derived at review time. | No. | Alias/merge operations do not retain durable provisional lineage. |
|
||||||
|
|
||||||
|
The current system can answer "what chapter/scene is being analysed now?" and "what scene results exist?" reasonably well. It cannot reliably answer "in what order were characters discovered?", "when did confidence change?", "which provisional identities merged?", or "what did a reconnecting graph client miss?" without adding durable event/discovery storage.
|
||||||
|
|
||||||
|
## 5. Canonical data model relevant to the graph
|
||||||
|
|
||||||
|
Major graph-ready canonical records:
|
||||||
|
|
||||||
|
| Record | Tables / procedures | Models / repositories | Suitability |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Projects, books, chapters, scenes | `Projects`, `Books`, `Chapters`, `Scenes`; `Project_*`, `Book_*`, `Chapter_*`, `Scene_*` | `ProjectRepository`, `BookRepository`, `ChapterRepository`, `SceneRepository` | Strong node base. Ordering by book/chapter/scene is significant. Soft archive applies. |
|
||||||
|
| Characters and appearances | `Characters`, `CharacterAliases`, `SceneCharacters` | `CharacterRepository` | Strong character nodes and scene edges. Alias graph needs interpretation. |
|
||||||
|
| Relationships | `CharacterRelationships`, `RelationshipEvents`, relationship categories/types | `CharacterRepository`, `RelationshipMapService` | Already feeds Cytoscape relationship map. Good edge source. |
|
||||||
|
| Character knowledge | `CharacterKnowledge`, knowledge states | `CharacterRepository` | Useful for knowledge nodes/edges, but evidence lineage limited. |
|
||||||
|
| Locations | `Locations`, `LocationAliases`, `LocationRelationships`, scene-location/cross refs | `LocationRepository` | Good location nodes; hierarchy and relationships need graph mapping. |
|
||||||
|
| Assets | `StoryAssets`, `AssetAliases`, `AssetEvents`, `AssetCustodyEvents`, dependencies | `AssetRepository` | Good asset nodes and custody/ownership edges. |
|
||||||
|
| Timeline/scene metrics | Timeline procedures, `SceneMetric*`, scene date/time fields | `TimelineRepository`, metric repositories | Good timeline layering, not a standalone graph yet. |
|
||||||
|
| Plot lines/threads | `PlotLines`, plot thread procedures/events | `PlotRepository` | Good thread nodes/edges when events are included. |
|
||||||
|
| Continuity warnings | `ContinuityWarnings`, acknowledgements | `WarningRepository`, `ContinuityWarningAcknowledgementRepository` | Useful issue nodes attached to scenes/entities. |
|
||||||
|
| Project activity | `ProjectActivity` | `ProjectActivityRepository` | Audit/activity feed, not detailed enough for graph replay. |
|
||||||
|
|
||||||
|
Existing canonical data is suitable for permanent Story Map snapshots, but the graph service should translate domain records into graph nodes/edges rather than exposing database DTOs directly.
|
||||||
|
|
||||||
|
## 6. Existing SignalR infrastructure
|
||||||
|
|
||||||
|
SignalR is registered in `Program.cs` with `MaximumReceiveMessageSize = 1 MB`.
|
||||||
|
|
||||||
|
Hubs:
|
||||||
|
|
||||||
|
- `/hubs/word-companion-follow` -> `WordCompanionFollowHub`, `[Authorize]`
|
||||||
|
- `/hubs/story-intelligence` -> `StoryIntelligenceHub`, `[Authorize]`
|
||||||
|
|
||||||
|
Group model:
|
||||||
|
|
||||||
|
- Word Companion uses `word-companion-presence:{userId}`.
|
||||||
|
- Story Intelligence uses `story-intelligence:{userId}`.
|
||||||
|
|
||||||
|
Client usage:
|
||||||
|
|
||||||
|
- `PlotLine/Views/Shared/_Layout.cshtml` includes SignalR from CDN for authenticated users.
|
||||||
|
- `PlotLine/Views/WordCompanionHost/Index.cshtml` includes SignalR from CDN.
|
||||||
|
- `PlotLine/wwwroot/js/story-intelligence-progress.js` connects to `/hubs/story-intelligence`, uses automatic reconnect, invokes watch methods after connect/reconnect, and handles `StoryIntelligenceProgressChanged` plus `StoryIntelligenceRunProgressChanged`.
|
||||||
|
|
||||||
|
Assessment:
|
||||||
|
|
||||||
|
- Suitable for progress updates and first visualisation transport.
|
||||||
|
- Current group scope is user-wide, not job/book-specific.
|
||||||
|
- Watch methods validate run ownership by `UserID`, but future graph groups should validate project/book/job access before joining.
|
||||||
|
- Messages are ephemeral. Missed recovery currently happens by reloading current run state, not by replaying events.
|
||||||
|
- No scale-out/backplane or sticky-session configuration was found. Deployment docs only cover upload storage; nginx/WebSocket settings were not present in the repo.
|
||||||
|
|
||||||
|
## 7. Existing frontend architecture
|
||||||
|
|
||||||
|
PlotLine is mostly server-rendered Razor with plain JavaScript and CSS:
|
||||||
|
|
||||||
|
- No PlotLine `package.json` was found.
|
||||||
|
- `PlotDirector.WordCompanion/package.json` exists only for the Office add-in manifest tooling.
|
||||||
|
- `bundleconfig.json` minifies selected CSS/JS files.
|
||||||
|
- Bootstrap is served from `wwwroot/lib/bootstrap`; the CSS appears to be Bootstrap 5.
|
||||||
|
- jQuery and validation libraries exist under `wwwroot/lib`.
|
||||||
|
- Theme support exists through `data-theme` / `data-bs-theme`, `site.css`, `plotline-theme.css`, and localStorage.
|
||||||
|
- Current JS is directly served/minified; no TypeScript build or module bundler is present for the MVC app.
|
||||||
|
- Existing components include Bootstrap modals, popovers, dropdowns, accordions, details-based drawers, a scene inspector drawer, and rich CSS theme audit pages.
|
||||||
|
- `prefers-reduced-motion` appears in CSS. Relationship Map disables layout animation.
|
||||||
|
|
||||||
|
Graph-related precedent:
|
||||||
|
|
||||||
|
- `RelationshipMap/Index.cshtml` loads Cytoscape from CDN and renders an interactive character relationship graph with pan/zoom, layout, edge selection, detail panel, resize handling and dark-theme styles.
|
||||||
|
- No Sigma.js or Graphology usage was found.
|
||||||
|
|
||||||
|
Sigma.js/Graphology integration options:
|
||||||
|
|
||||||
|
- Best long-term option: add an MVC frontend package/build pipeline and bundle Sigma/Graphology/worker modules with cache busting.
|
||||||
|
- Short-term prototype option: load from CDN like Cytoscape, but that conflicts with a durable production-grade graph engine and CSP/offline control.
|
||||||
|
- TypeScript is not currently established in PlotLine; Phase 21B should decide whether to introduce it with a minimal build step.
|
||||||
|
|
||||||
|
## 8. Relevant pages and navigation
|
||||||
|
|
||||||
|
Relevant routes:
|
||||||
|
|
||||||
|
- `/onboarding` -> onboarding wizard and Word scan controls.
|
||||||
|
- `/onboarding/scan-review` -> chapter/scene scan review.
|
||||||
|
- `/onboarding/story-intelligence` -> pre-analysis overview.
|
||||||
|
- `/onboarding/story-intelligence/progress?batchId=...` -> current progress page.
|
||||||
|
- `/onboarding/story-intelligence/review?batchId=...` -> scene review/import.
|
||||||
|
- `/onboarding/story-intelligence/characters|locations|assets|relationships|knowledge` -> staged entity review.
|
||||||
|
- `/onboarding/story-intelligence/book/{bookId}/continue` -> resume from book.
|
||||||
|
- `/RelationshipMap?projectId=...` -> current graph visualisation.
|
||||||
|
- `/Projects`, `/Books/Details`, `/Chapters/Details`, `/Scenes/Edit`, `/Characters`, `/Locations`, `/StoryAssets`, `/Timeline`, `/PlotThreads`, `/ContinuityExplorer`, `/Warnings`.
|
||||||
|
|
||||||
|
The current layout can host a graph, but the full-screen visualisation should probably use a minimal-chrome shell. No established full-screen graph canvas shell was found. Relationship Map has a graph workspace plus details panel that can be reused conceptually.
|
||||||
|
|
||||||
|
## 9. Security and authorisation assessment
|
||||||
|
|
||||||
|
Current strengths:
|
||||||
|
|
||||||
|
- Controllers are generally `[Authorize]`.
|
||||||
|
- `ProjectAccessFilter` globally checks project/entity access based on controller parameters and model properties.
|
||||||
|
- Project collaborators and owners are handled through `ProjectAccess_*` stored procedures.
|
||||||
|
- Story Intelligence hub methods check authenticated user ID; `WatchStoryIntelligenceRun` checks `run.UserID == userId`.
|
||||||
|
- Razor output is encoded by default.
|
||||||
|
|
||||||
|
Risks/gaps for the visualisation:
|
||||||
|
|
||||||
|
- Future graph APIs cannot rely only on `run.UserID`; collaborative project access and book-level access must be checked.
|
||||||
|
- User-group SignalR broadcasts could deliver all a user's Story Intelligence progress to every tab. A graph should use job/book groups after explicit authorisation.
|
||||||
|
- Manuscript excerpts in snapshots/evidence drawers need strict project/book/run ownership checks.
|
||||||
|
- CDN scripts complicate future CSP.
|
||||||
|
- Uploaded portrait/cover URLs should be served through existing upload path rules and not blindly embedded from AI output.
|
||||||
|
|
||||||
|
Recommended graph access:
|
||||||
|
|
||||||
|
- Snapshot endpoint: require authenticated user and `ProjectAccess_UserCanAccessEntity` for the book/run/project.
|
||||||
|
- Hub group join: `WatchStoryIntelligenceGraph(runId, afterSequence)` validates the same access, then joins `story-intelligence-run:{runId}`.
|
||||||
|
- Do not expose raw prompt internals or private reasoning; expose short manuscript evidence and structured user-facing justifications.
|
||||||
|
|
||||||
|
## 10. Stable identity assessment
|
||||||
|
|
||||||
|
Canonical IDs are stable (`project-{id}`, `book-{id}`, `chapter-{id}`, `scene-{id}`, `character-{id}`, `location-{id}`, `asset-{id}`, `relationship-{id}`, `knowledge-{id}`).
|
||||||
|
|
||||||
|
Current provisional stability:
|
||||||
|
|
||||||
|
- `StoryIntelligenceRunID`, `ChapterResultID`, and `SceneResultID` are stable.
|
||||||
|
- Temporary scan keys are stable only within the scan preview memory store.
|
||||||
|
- Review candidate keys for characters/locations/assets/relationships/knowledge are derived and not durable.
|
||||||
|
- Merges/aliases do not have a durable provisional-to-canonical lineage.
|
||||||
|
|
||||||
|
Recommended convention, pending future storage:
|
||||||
|
|
||||||
|
- `run-{runId}`
|
||||||
|
- `chapter-result-{chapterResultId}`
|
||||||
|
- `scene-result-{sceneResultId}`
|
||||||
|
- `provisional-character-{discoveryId}` -> merge/replace to `character-{characterId}`
|
||||||
|
- `provisional-location-{discoveryId}` -> `location-{locationId}`
|
||||||
|
- `provisional-asset-{discoveryId}` -> `asset-{assetId}`
|
||||||
|
- `provisional-relationship-{discoveryId}` -> `relationship-{characterRelationshipId}`
|
||||||
|
- `provisional-knowledge-{discoveryId}` -> `knowledge-{characterKnowledgeId}`
|
||||||
|
|
||||||
|
The client will need an explicit `IdentityMerged` or `NodePromoted` event to preserve visual continuity when provisional nodes become canonical.
|
||||||
|
|
||||||
|
## 11. Evidence and confidence assessment
|
||||||
|
|
||||||
|
Stored today:
|
||||||
|
|
||||||
|
- Source text on `StoryIntelligenceRuns`.
|
||||||
|
- Raw response JSON, assistant output JSON and parsed JSON for chapter/scene results.
|
||||||
|
- Validation errors/warnings counts.
|
||||||
|
- Confidence values inside parsed scene JSON for many extracted facts.
|
||||||
|
- Source scene/chapter through run, chapter result and scene result linkage.
|
||||||
|
- Canonical import commit metadata and warnings.
|
||||||
|
|
||||||
|
Not stored in a graph-ready way:
|
||||||
|
|
||||||
|
- Normalised evidence records per claim.
|
||||||
|
- Short source excerpts per discovery.
|
||||||
|
- Confidence history.
|
||||||
|
- Alternative candidates.
|
||||||
|
- Confirmation or contradictory evidence across later scenes.
|
||||||
|
- Manual override/rejection reason as durable data.
|
||||||
|
- User-facing reasoning summaries separate from raw AI output.
|
||||||
|
|
||||||
|
The future evidence drawer can be supported if Phase 21 adds durable discovery/evidence rows derived from parsed scene results, not from private model reasoning.
|
||||||
|
|
||||||
|
## 12. Event-stream assessment
|
||||||
|
|
||||||
|
Existing mechanisms:
|
||||||
|
|
||||||
|
- `ProjectActivity` and `ProjectAudit_Record`: useful general audit trail, not fine-grained Story Intelligence replay.
|
||||||
|
- SignalR progress events: ephemeral.
|
||||||
|
- Import commit rows: durable finalisation record, not incremental discovery history.
|
||||||
|
- SQL result tables: durable snapshots of prompt outputs, not ordered graph events.
|
||||||
|
|
||||||
|
Needed event model:
|
||||||
|
|
||||||
|
- Dedicated `StoryIntelligenceVisualisationEvents` table.
|
||||||
|
- Monotonic `SequenceNumber` per run/job.
|
||||||
|
- Event type, timestamp, project/book/run/scene/discovery references.
|
||||||
|
- JSON payload with a version.
|
||||||
|
- Idempotency key.
|
||||||
|
- Retention rules.
|
||||||
|
- Optional processed/published metadata if an outbox publisher is introduced.
|
||||||
|
|
||||||
|
Candidate events:
|
||||||
|
|
||||||
|
`AnalysisStarted`, `StageChanged`, `ChapterAnalysisStarted`, `SceneAnalysisStarted`, `SceneAnalysisCompleted`, `NodeDiscovered`, `NodeUpdated`, `EdgeDiscovered`, `EdgeUpdated`, `IdentityMerged`, `RelationshipSuspected`, `RelationshipConfirmed`, `TimelineEventDiscovered`, `KnowledgeThreadOpened`, `KnowledgeThreadResolved`, `ObservationCreated`, `AnalysisCompleted`, `AnalysisFailed`, `NodePromotedToCanonical`, `DiscoveryRejected`.
|
||||||
|
|
||||||
|
## 13. Performance and scale assessment
|
||||||
|
|
||||||
|
Estimated graph sizes:
|
||||||
|
|
||||||
|
| Scope | Scenes | Characters | Locations | Assets | Relationships | Knowledge/events | Nodes | Edges |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| Small test | 5-15 | 5-12 | 3-8 | 3-10 | 5-20 | 10-40 | 30-90 | 50-150 |
|
||||||
|
| Normal novel | 60-120 | 25-80 | 30-120 | 40-180 | 80-250 | 150-600 | 300-1,100 | 700-3,000 |
|
||||||
|
| Large novel | 150-250 | 80-180 | 100-250 | 150-400 | 250-700 | 500-1,500 | 1,000-3,000 | 3,000-10,000 |
|
||||||
|
| Trilogy | 300-600 | 150-350 | 200-600 | 300-1,000 | 700-2,000 | 1,500-5,000 | 3,000-9,000 | 10,000-35,000 |
|
||||||
|
| Large multi-book project | 800+ | 400+ | 800+ | 1,500+ | 3,000+ | 8,000+ | 12,000+ | 50,000+ |
|
||||||
|
|
||||||
|
Primary risks:
|
||||||
|
|
||||||
|
- JSON payload size for snapshots and replay.
|
||||||
|
- Browser layout cost for dense relationship/evidence edges.
|
||||||
|
- Portrait textures and labels.
|
||||||
|
- Replaying thousands of small events after reconnect.
|
||||||
|
- SQL DTO construction from many canonical tables.
|
||||||
|
|
||||||
|
Recommendation: Phase 21B should target one book / one active analysis batch, with filters and level-of-detail from the start. Project-wide exploration should come later.
|
||||||
|
|
||||||
|
## 14. Reconnection and recovery assessment
|
||||||
|
|
||||||
|
Current recovery:
|
||||||
|
|
||||||
|
- Progress JS reconnects and invokes watch methods to get current run state.
|
||||||
|
- Browser refresh can reload progress from SQL if the batch still exists.
|
||||||
|
- Closing Word does not stop queued analysis.
|
||||||
|
|
||||||
|
Gaps:
|
||||||
|
|
||||||
|
- In-memory scan preview and batch store do not survive application restart.
|
||||||
|
- SignalR missed events are not replayable.
|
||||||
|
- No event sequence exists.
|
||||||
|
- No graph snapshot endpoint exists.
|
||||||
|
|
||||||
|
Required Phase 21 design:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Browser opens visualisation"] --> B["GET graph snapshot"]
|
||||||
|
B --> C["Snapshot includes lastSequence"]
|
||||||
|
C --> D["Join authorised SignalR run group"]
|
||||||
|
D --> E["Receive events with sequence numbers"]
|
||||||
|
E --> F["Apply idempotently to Graphology"]
|
||||||
|
F --> G["Reconnect"]
|
||||||
|
G --> H["Request events after last applied sequence"]
|
||||||
|
H --> F
|
||||||
|
H --> I["If too old, reload snapshot"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15. Approval-to-canonical transition assessment
|
||||||
|
|
||||||
|
Scene import:
|
||||||
|
|
||||||
|
- `StoryIntelligenceImportCommitService` prepares completed run results.
|
||||||
|
- SQL commit creates canonical scenes/metrics and records `StoryIntelligenceImportCommits`.
|
||||||
|
- Pipeline state records scene import.
|
||||||
|
- Duplicate scene import is blocked by readiness checks.
|
||||||
|
|
||||||
|
Entity imports:
|
||||||
|
|
||||||
|
- Review candidates are derived from committed scenes and saved parsed JSON.
|
||||||
|
- Create/link/alias/ignore actions call existing canonical repositories.
|
||||||
|
- Pipeline state advances per book.
|
||||||
|
- Decisions and last import summaries are in the batch object, not durable review tables.
|
||||||
|
|
||||||
|
Risks:
|
||||||
|
|
||||||
|
- Provisional node IDs will disappear unless durable discovery IDs are introduced.
|
||||||
|
- Rejections/aliases are not reliably reconstructable after restart.
|
||||||
|
- Partial approval can change graph topology without an event record.
|
||||||
|
- Relationships can reference provisional characters unless promotion/merge ordering is explicit.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["Parsed scene result"] --> B["Provisional discovery row"]
|
||||||
|
B --> C["Graph provisional node"]
|
||||||
|
C --> D{"Author decision"}
|
||||||
|
D -->|Create| E["Canonical record created"]
|
||||||
|
D -->|Link| F["Existing canonical record linked"]
|
||||||
|
D -->|Alias/Merge| G["Discovery merged into target"]
|
||||||
|
D -->|Ignore| H["Discovery rejected"]
|
||||||
|
E --> I["NodePromotedToCanonical event"]
|
||||||
|
F --> I
|
||||||
|
G --> J["IdentityMerged event"]
|
||||||
|
H --> K["DiscoveryRejected event"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 16. Proposed Phase 21 architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["Story Intelligence analysis worker"] --> B["Persist discoveries and state"]
|
||||||
|
B --> C["SQL Server"]
|
||||||
|
C --> D["Ordered visualisation event table"]
|
||||||
|
D --> E["Story Intelligence event publisher"]
|
||||||
|
E --> F["SignalR hub"]
|
||||||
|
F --> G["Browser client"]
|
||||||
|
G --> H["Snapshot loader"]
|
||||||
|
G --> I["Event applier"]
|
||||||
|
H --> J["Graphology graph model"]
|
||||||
|
I --> J
|
||||||
|
J --> K["Sigma.js renderer"]
|
||||||
|
G --> L["Panels, feeds, filters, evidence drawer"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Principles:
|
||||||
|
|
||||||
|
- SQL Server is authoritative.
|
||||||
|
- SignalR is transport only.
|
||||||
|
- Browser never analyses manuscript.
|
||||||
|
- Graph DTOs are versioned and separate from database DTOs.
|
||||||
|
- The same graph engine supports live analysis and completed Story Map.
|
||||||
|
|
||||||
|
## 17. Proposed server-side components
|
||||||
|
|
||||||
|
- `StoryIntelligenceVisualisationSnapshotService`: builds snapshot for run/book/project.
|
||||||
|
- `StoryIntelligenceVisualisationEventRepository`: persists ordered events.
|
||||||
|
- `StoryIntelligenceDiscoveryRepository`: persists provisional nodes/edges/evidence.
|
||||||
|
- `StoryIntelligenceVisualisationPublisher`: publishes stored events to SignalR after commit.
|
||||||
|
- `StoryIntelligenceGraphHub`: job/book-scoped groups with explicit access validation.
|
||||||
|
- `StoryIntelligenceGraphController` or API controller: snapshot and missed-event endpoints.
|
||||||
|
- Integration points in `PersistedStoryIntelligenceRunner` and import services to write discovery/event rows.
|
||||||
|
|
||||||
|
## 18. Proposed browser-side components
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Razor page"] --> B["graph bootstrap"]
|
||||||
|
B --> C["snapshot API client"]
|
||||||
|
B --> D["SignalR client"]
|
||||||
|
C --> E["graph adapter"]
|
||||||
|
D --> E
|
||||||
|
E --> F["Graphology"]
|
||||||
|
F --> G["Sigma renderer"]
|
||||||
|
E --> H["activity feed"]
|
||||||
|
E --> I["evidence drawer"]
|
||||||
|
E --> J["filters/search"]
|
||||||
|
K["layout worker"] --> F
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested modules:
|
||||||
|
|
||||||
|
- `story-graph-client`
|
||||||
|
- `story-graph-adapter`
|
||||||
|
- `story-graph-renderer`
|
||||||
|
- `story-graph-layout-worker`
|
||||||
|
- `story-graph-panels`
|
||||||
|
- `story-graph-api`
|
||||||
|
|
||||||
|
## 19. Proposed API contracts
|
||||||
|
|
||||||
|
Snapshot:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/story-intelligence/runs/{runId}/graph-snapshot?scope=book
|
||||||
|
```
|
||||||
|
|
||||||
|
Response shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"runId": 123,
|
||||||
|
"projectId": 10,
|
||||||
|
"bookId": 20,
|
||||||
|
"lastSequence": 456,
|
||||||
|
"nodes": [],
|
||||||
|
"edges": [],
|
||||||
|
"activeStage": "SceneIntelligence",
|
||||||
|
"permissions": { "canReview": true },
|
||||||
|
"generatedUtc": "2026-07-11T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Missed events:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/story-intelligence/runs/{runId}/graph-events?afterSequence=456
|
||||||
|
```
|
||||||
|
|
||||||
|
## 20. Proposed SignalR message contracts
|
||||||
|
|
||||||
|
Hub route proposal: `/hubs/story-intelligence-graph`
|
||||||
|
|
||||||
|
Client invokes:
|
||||||
|
|
||||||
|
- `WatchRunGraph(runId, afterSequence)`
|
||||||
|
- `LeaveRunGraph(runId)`
|
||||||
|
|
||||||
|
Server sends:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"runId": 123,
|
||||||
|
"sequence": 457,
|
||||||
|
"eventId": "01J...",
|
||||||
|
"type": "NodeDiscovered",
|
||||||
|
"occurredUtc": "2026-07-11T00:00:00Z",
|
||||||
|
"payloadVersion": 1,
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 21. Proposed event categories
|
||||||
|
|
||||||
|
- Analysis lifecycle
|
||||||
|
- Progress and stage
|
||||||
|
- Scene/chapter lifecycle
|
||||||
|
- Discovery node lifecycle
|
||||||
|
- Relationship/edge lifecycle
|
||||||
|
- Evidence/confidence updates
|
||||||
|
- Merge/promotion/rejection
|
||||||
|
- Review/approval
|
||||||
|
- Failure/cancellation/recovery
|
||||||
|
|
||||||
|
## 22. Proposed implementation sequence
|
||||||
|
|
||||||
|
1. Add durable event/discovery design and SQL script.
|
||||||
|
2. Add read-only snapshot service over existing canonical plus run-result data.
|
||||||
|
3. Add event writer around current runner progress and scene result persistence.
|
||||||
|
4. Add graph hub with authorised job groups and replay-after-sequence.
|
||||||
|
5. Build browser graph adapter with mocked renderer data from real snapshots.
|
||||||
|
6. Integrate Sigma.js/Graphology and layout worker.
|
||||||
|
7. Add live progress/discovery events.
|
||||||
|
8. Add approval/promotion events for scene and entity imports.
|
||||||
|
9. Add completed Story Map route reusing the graph engine.
|
||||||
|
|
||||||
|
## 23. Database changes likely to be required
|
||||||
|
|
||||||
|
Follow existing SQL script convention in `PlotLine/Sql`, where the current latest script is `134_Phase20AV_ArchivedBookProjectPermanentDeletion.sql`.
|
||||||
|
|
||||||
|
Likely future script:
|
||||||
|
|
||||||
|
- `135_Phase21B_StoryIntelligenceVisualisationEvents.sql`
|
||||||
|
|
||||||
|
Likely tables:
|
||||||
|
|
||||||
|
- `StoryIntelligenceDiscoveries`
|
||||||
|
- `StoryIntelligenceDiscoveryEvidence`
|
||||||
|
- `StoryIntelligenceDiscoveryAliases`
|
||||||
|
- `StoryIntelligenceVisualisationEvents`
|
||||||
|
- Possibly `StoryIntelligenceGraphSnapshots` if snapshot materialisation is needed later.
|
||||||
|
|
||||||
|
Conventions to follow:
|
||||||
|
|
||||||
|
- Idempotent `IF OBJECT_ID` / `COL_LENGTH` checks.
|
||||||
|
- `CREATE OR ALTER PROCEDURE`.
|
||||||
|
- `PK_`, `FK_`, `DF_`, `CK_`, `IX_` naming.
|
||||||
|
- User/project/book isolation columns and indexes.
|
||||||
|
- Do not alter historical scripts.
|
||||||
|
|
||||||
|
## 24. Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Volatile batch state prevents recovery | Persist batch/run mappings and review decisions or derive them from durable pipeline rows. |
|
||||||
|
| SignalR events missed | Store ordered events and replay after sequence. |
|
||||||
|
| Graph too dense | One-book scope, filters, clustering, label level-of-detail. |
|
||||||
|
| Provisional/canonical ID churn | Add discovery IDs and explicit promotion/merge events. |
|
||||||
|
| Manuscript data leakage | Snapshot/hub access checks against project/book/run. |
|
||||||
|
| Package integration churn | Decide MVC frontend build approach before adding Sigma/Graphology. |
|
||||||
|
| CDN/CSP concerns | Prefer bundled assets for production. |
|
||||||
|
| Existing progress workflow regression | Add visualisation beside current progress page first. |
|
||||||
|
|
||||||
|
## 25. Decisions still required
|
||||||
|
|
||||||
|
- Whether Phase 21B introduces an MVC `package.json` and bundler.
|
||||||
|
- Whether to persist batch state or replace batch IDs with durable book/run pipeline IDs.
|
||||||
|
- Exact discovery/event SQL schema.
|
||||||
|
- Whether to keep Cytoscape for Relationship Map or migrate it later to the new engine.
|
||||||
|
- Retention policy for raw source excerpts and visualisation events.
|
||||||
|
- Whether snapshots are generated on demand or materialised.
|
||||||
|
|
||||||
|
## 26. Files inspected
|
||||||
|
|
||||||
|
Primary files inspected:
|
||||||
|
|
||||||
|
- `PlotLine/Program.cs`
|
||||||
|
- `PlotLine/Hubs/StoryIntelligenceHub.cs`
|
||||||
|
- `PlotLine/Hubs/WordCompanionFollowHub.cs`
|
||||||
|
- `PlotLine/Controllers/OnboardingController.cs`
|
||||||
|
- `PlotLine/Controllers/RelationshipMapController.cs`
|
||||||
|
- `PlotLine/Services/OnboardingService.cs`
|
||||||
|
- `PlotLine/Services/OnboardingStoryIntelligenceService.cs`
|
||||||
|
- `PlotLine/Services/PersistedStoryIntelligenceWorker.cs`
|
||||||
|
- `PlotLine/Services/PersistedStoryIntelligenceRunner.cs`
|
||||||
|
- `PlotLine/Services/StoryIntelligenceProgressNotifier.cs`
|
||||||
|
- `PlotLine/Services/StoryIntelligenceImportCommitService.cs`
|
||||||
|
- `PlotLine/Services/SceneImportResolver.cs`
|
||||||
|
- `PlotLine/Services/StoryIntelligenceCharacterImportService.cs`
|
||||||
|
- `PlotLine/Services/StoryIntelligenceLocationImportService.cs`
|
||||||
|
- `PlotLine/Services/ProjectAccessFilter.cs`
|
||||||
|
- `PlotLine/Services/ProjectAccessServices.cs`
|
||||||
|
- `PlotLine/Data/StoryIntelligenceResultRepository.cs`
|
||||||
|
- `PlotLine/Data/StoryIntelligenceRepository.cs`
|
||||||
|
- `PlotLine/Data/StoryIntelligencePipelineRepository.cs`
|
||||||
|
- `PlotLine/Data/Repositories.cs`
|
||||||
|
- `PlotLine/Models/StoryIntelligenceModels.cs`
|
||||||
|
- `PlotLine/Models/StoryIntelligencePersistenceModels.cs`
|
||||||
|
- `PlotLine/ViewModels/OnboardingViewModels.cs`
|
||||||
|
- `PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml`
|
||||||
|
- `PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml`
|
||||||
|
- `PlotLine/Views/RelationshipMap/Index.cshtml`
|
||||||
|
- `PlotLine/Views/Shared/_Layout.cshtml`
|
||||||
|
- `PlotLine/wwwroot/js/story-intelligence-progress.js`
|
||||||
|
- `PlotLine/bundleconfig.json`
|
||||||
|
- `PlotDirector.WordCompanion/package.json`
|
||||||
|
- SQL scripts `001`, `004`, `005`, `006`, `007`, `020`, `033`, `039`, `042`, `046`, `088`, `093`, `111`, `114`, `115`, `117`, `121`, `123`, `124`, `127` through `134`
|
||||||
|
- `PlotLine/Docs/Features/PlotDirector_Story_Intelligence_Visualisation_Design.md`
|
||||||
|
- `PlotLine/Docs/Architecture/DeploymentConfiguration.md`
|
||||||
|
|
||||||
|
## 27. Recommended next step for Phase 21B
|
||||||
|
|
||||||
|
Phase 21B should be a narrow foundation phase:
|
||||||
|
|
||||||
|
1. Design and add durable graph discovery/event storage for one active `StoryIntelligenceRun`.
|
||||||
|
2. Add authorised snapshot and event replay endpoints.
|
||||||
|
3. Add a SignalR graph hub that joins one run group after access validation.
|
||||||
|
4. Emit lifecycle, stage, scene-start, scene-complete and basic discovery events from the existing persisted runner.
|
||||||
|
5. Do not replace the current progress page yet; add the visualisation as an optional "Watch Story Intelligence" experience backed by the same SQL state.
|
||||||
123
docs/phases/Phase-21B-Story-Intelligence-Experience-Prototype.md
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
# Phase 21B - Story Intelligence Experience Prototype
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 21B creates an isolated, development-only visual prototype for the PlotDirector Story Intelligence Experience. The product specification is `PlotLine/Docs/Features/PlotDirector_Story_Intelligence_Experience_Design_v2.md`; this note records the implementation approach and limits.
|
||||||
|
|
||||||
|
## Route
|
||||||
|
|
||||||
|
- `/Development/StoryIntelligenceExperience`
|
||||||
|
- Implemented by `DevelopmentController`.
|
||||||
|
- Requires the existing `AdminOnly` policy.
|
||||||
|
- Returns `NotFound` unless `IWebHostEnvironment.IsDevelopment()` is true.
|
||||||
|
- Not linked from normal navigation.
|
||||||
|
|
||||||
|
## Files added or changed
|
||||||
|
|
||||||
|
- `PlotLine/Controllers/DevelopmentController.cs`
|
||||||
|
- `PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs`
|
||||||
|
- `PlotLine/Services/StoryIntelligenceExperiencePrototypeData.cs`
|
||||||
|
- `PlotLine/Views/Development/StoryIntelligenceExperience.cshtml`
|
||||||
|
- `PlotLine/wwwroot/css/story-intelligence-experience-prototype.css`
|
||||||
|
- `PlotLine/wwwroot/js/story-intelligence-experience-prototype.js`
|
||||||
|
- `PlotLine/wwwroot/images/story-intelligence/prototype/*.svg`
|
||||||
|
- `docs/phases/Phase-21B-Story-Intelligence-Experience-Prototype.md`
|
||||||
|
|
||||||
|
No existing Story Intelligence import, progress, SignalR, worker, persistence or database files were changed.
|
||||||
|
|
||||||
|
## Prototype data structure
|
||||||
|
|
||||||
|
The prototype uses `StoryIntelligenceExperiencePrototypeData.Build()` to provide simulated Alpha Flame-style scene states. Each state includes:
|
||||||
|
|
||||||
|
- Scene number, title, summary and stage
|
||||||
|
- Progress figures and estimated remaining time
|
||||||
|
- POV character
|
||||||
|
- Important and incidental characters
|
||||||
|
- One active location
|
||||||
|
- Significant assets
|
||||||
|
- Relationship changes
|
||||||
|
- Knowledge threads
|
||||||
|
- Story observations
|
||||||
|
- Timeline label/event
|
||||||
|
- Latest discoveries and activity feed
|
||||||
|
|
||||||
|
The Razor view serializes this model into a JSON script tag. The JavaScript renderer consumes that state and reconciles DOM elements by stable prototype IDs.
|
||||||
|
|
||||||
|
## Rendering approach
|
||||||
|
|
||||||
|
The prototype uses a hybrid renderer:
|
||||||
|
|
||||||
|
- HTML/CSS for the full-screen shell, panels, imagery, progress, scene ribbon and timeline.
|
||||||
|
- SVG for relationship and location connection lines.
|
||||||
|
- JavaScript for current-to-next state reconciliation and playback controls.
|
||||||
|
|
||||||
|
Cytoscape was not reused. It remains useful for the existing Relationship Map, but this prototype is not a free-form network graph. The experience needs authored composition around POV, location, assets, narrative rhythm and side-panel understanding, which is better tested here with HTML/CSS/SVG.
|
||||||
|
|
||||||
|
## Animation approach
|
||||||
|
|
||||||
|
The JavaScript updates from current scene state to next scene state and reconciles:
|
||||||
|
|
||||||
|
- Character prominence, position and role changes
|
||||||
|
- POV changes
|
||||||
|
- Location crossfades
|
||||||
|
- Asset appearance/removal/prominence
|
||||||
|
- Relationship line drawing
|
||||||
|
- Insight panel updates
|
||||||
|
- Scene ribbon movement
|
||||||
|
- Timeline growth
|
||||||
|
- Progress/stat changes
|
||||||
|
|
||||||
|
The default automatic playback interval is controlled by the prototype speed slider. Manual controls allow play/pause, previous, next and restart for development review only.
|
||||||
|
|
||||||
|
## Imagery approach
|
||||||
|
|
||||||
|
Prototype-only SVG artwork is stored under:
|
||||||
|
|
||||||
|
`PlotLine/wwwroot/images/story-intelligence/prototype/`
|
||||||
|
|
||||||
|
The artwork includes character portraits, locations and assets. It is intentionally isolated so it can later be replaced by curated canonical imagery and should not be treated as final artwork.
|
||||||
|
|
||||||
|
## Accessibility and reduced motion
|
||||||
|
|
||||||
|
The prototype provides:
|
||||||
|
|
||||||
|
- Ordinary HTML text for progress, status, discoveries and insights.
|
||||||
|
- Button elements for all controls.
|
||||||
|
- Visible focus styles.
|
||||||
|
- Semantic sections and labelled side panels.
|
||||||
|
- `prefers-reduced-motion` support that shortens transitions and disables animated line drawing.
|
||||||
|
|
||||||
|
## Responsive behaviour
|
||||||
|
|
||||||
|
The primary target is desktop/laptop. At narrower widths, the layout stacks and the page becomes scrollable so the current-story view remains usable rather than shrinking into unreadable tiles. A polished mobile version is deferred.
|
||||||
|
|
||||||
|
## Technical limitations
|
||||||
|
|
||||||
|
- Uses simulated data only.
|
||||||
|
- No SignalR or API integration.
|
||||||
|
- No database storage.
|
||||||
|
- No durable event stream.
|
||||||
|
- No real import pipeline connection.
|
||||||
|
- No post-import Story Map.
|
||||||
|
- No frontend package manager or bundler was introduced.
|
||||||
|
- The prototype view includes its own full-screen document shell rather than the standard layout.
|
||||||
|
|
||||||
|
## Items intentionally deferred
|
||||||
|
|
||||||
|
- Real Story Intelligence data binding.
|
||||||
|
- Snapshot/replay endpoints.
|
||||||
|
- Durable provisional discovery IDs.
|
||||||
|
- Event-history tables.
|
||||||
|
- Production navigation.
|
||||||
|
- Sigma.js/Graphology evaluation.
|
||||||
|
- Permanent Story Map route.
|
||||||
|
- Mobile polish.
|
||||||
|
|
||||||
|
## Recommendations for Phase 21C
|
||||||
|
|
||||||
|
Phase 21C should keep the current progress page intact while adding the first real data seam behind the prototype:
|
||||||
|
|
||||||
|
1. Define a read-only snapshot contract shaped like the prototype scene state.
|
||||||
|
2. Map existing `StoryIntelligenceRuns` and `StoryIntelligenceSceneResults` into that contract.
|
||||||
|
3. Add durable discovery/event storage only after the snapshot shape is validated.
|
||||||
|
4. Preserve the same browser-side state reconciliation API so simulated data can be replaced incrementally.
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
# Phase 21B.2 - Story Intelligence Experience Composition Refinement
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Phase 21B.2 refines the development-only Story Intelligence Experience prototype so the Living Story View reads as one connected narrative map rather than separate character, location and asset regions.
|
||||||
|
|
||||||
|
This phase remains visual-only. It does not connect real Story Intelligence data, add SignalR, alter the import workflow, expose production navigation, create SQL, or modify the database.
|
||||||
|
|
||||||
|
## Composition Changes
|
||||||
|
|
||||||
|
- The central map container border and background were greatly softened.
|
||||||
|
- Characters, the active location and significant assets now share a single visual canvas.
|
||||||
|
- The scene title was reduced so the map has more vertical room.
|
||||||
|
- Category labels remain only as faint positional guides.
|
||||||
|
- The background now carries a subtler star/network texture behind the entities.
|
||||||
|
|
||||||
|
## Circular Location
|
||||||
|
|
||||||
|
The active location is now rendered as a circular node with the same broad visual language as character and asset nodes:
|
||||||
|
|
||||||
|
- Circular image mask
|
||||||
|
- Soft illuminated border
|
||||||
|
- Location name and secondary description below the node
|
||||||
|
- Stable `data-location-id` connection target
|
||||||
|
|
||||||
|
The existing prototype SVG location artwork is still used and masked with `object-fit: cover`, so future curated images can replace it without changing the layout contract.
|
||||||
|
|
||||||
|
## Circular Assets
|
||||||
|
|
||||||
|
Assets in the main Living Story View now render as circular image-first nodes:
|
||||||
|
|
||||||
|
- Circular image/avatar
|
||||||
|
- Label and state text underneath
|
||||||
|
- Size influenced by simulated narrative weight
|
||||||
|
- Stable `data-asset-id` connection targets
|
||||||
|
|
||||||
|
The compact discovery feed indicators were left unchanged because they serve a different scanning purpose in the left panel.
|
||||||
|
|
||||||
|
## Connection Lines
|
||||||
|
|
||||||
|
The SVG connection layer now draws multiple current-scene connection categories:
|
||||||
|
|
||||||
|
- Character relationship
|
||||||
|
- Character present at location
|
||||||
|
- Asset found at location
|
||||||
|
- Character knowledge or evidence connection to assets
|
||||||
|
- Asset-to-asset supporting evidence link
|
||||||
|
|
||||||
|
Connections use curved paths and category-specific styling. Supportive, uncertain, conflict, presence, evidence and knowledge lines are distinguishable by colour and line style rather than colour alone.
|
||||||
|
|
||||||
|
## Inline Relationship Labels
|
||||||
|
|
||||||
|
The central map now renders one or two inline relationship labels per scene on selected character-to-character connections. These labels summarize the active relationship state, while the right-hand Relationship Changes panel continues to show the latest detected changes.
|
||||||
|
|
||||||
|
Examples include:
|
||||||
|
|
||||||
|
- `trust strengthened`
|
||||||
|
- `tension increasing`
|
||||||
|
- `family`
|
||||||
|
- `questions`
|
||||||
|
- `motive exposed`
|
||||||
|
|
||||||
|
## Analysis Stage Progress
|
||||||
|
|
||||||
|
A compact analysis-stage rail was added to the header. It advances with simulated scene state and uses live-analysis stage labels:
|
||||||
|
|
||||||
|
- Reading
|
||||||
|
- Chapters
|
||||||
|
- Scenes
|
||||||
|
- Content
|
||||||
|
- Characters
|
||||||
|
- Locations
|
||||||
|
- Assets
|
||||||
|
- Relationships
|
||||||
|
- Knowledge
|
||||||
|
- Continuity
|
||||||
|
- Finalising
|
||||||
|
|
||||||
|
The real Story Intelligence pipeline classes were inspected, but not changed. This rail is prototype-only and maps the existing simulated scene `stage` and `progressPercent` values onto a compact display.
|
||||||
|
|
||||||
|
## Responsive Behaviour
|
||||||
|
|
||||||
|
The main desktop target remains 1920 x 1080 through 1366 x 768. At narrower widths the stage rail can scroll horizontally and the existing stacked layout is retained. Full mobile optimisation remains deferred.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- The map positions are still authored prototype positions, not graph-solved layout.
|
||||||
|
- SVG connection labels are intentionally sparse to avoid turning the experience into a dense generic graph.
|
||||||
|
- Location and asset artwork is still prototype SVG artwork, not final curated imagery.
|
||||||
|
- No historical replay/event store exists yet.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Real data binding
|
||||||
|
- Durable Story Intelligence snapshot contract
|
||||||
|
- Production route/navigation
|
||||||
|
- Final image library
|
||||||
|
- Mobile-specific composition pass
|
||||||
|
- Phase 21C real-data integration
|
||||||
103
docs/phases/Phase-21C-Story-Intelligence-Illustration-Library.md
Normal 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.
|
||||||
@ -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.
|
||||||
@ -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.
|
||||||