From 2bb5decf63314f6e632f6a138bd657e433a4b2b9 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 24 Aug 2026 14:47:29 +0000 Subject: [PATCH] Remove Story Intelligence visualisation runtime --- PlotLine.Tests/Program.cs | 139 +- PlotLine/Controllers/DevelopmentController.cs | 193 -- ...toryIntelligenceVisualisationController.cs | 61 - ...elligenceIllustrationMatchingRepository.cs | 117 - .../StoryIntelligencePipelineRepository.cs | 10 + ...yIntelligenceIllustrationMatchingModels.cs | 70 - .../StoryIntelligencePersistenceModels.cs | 16 + PlotLine/Program.cs | 5 +- PlotLine/Services/AuthServiceInterfaces.cs | 1 + PlotLine/Services/AuthServices.cs | 32 + .../Services/IllustrationLibraryServices.cs | 115 +- .../OnboardingStoryIntelligenceService.cs | 2 + .../PersistedStoryIntelligenceRunner.cs | 8 + ...toryIntelligenceDevelopmentResetService.cs | 9 +- ...IntelligenceExistingChapterQueueService.cs | 2 + ...StoryIntelligenceFullChapterTestService.cs | 2 + ...IntelligenceIllustrationMatchingService.cs | 1144 -------- .../StoryIntelligenceOperationalServices.cs | 90 + ...oryIntelligenceResultPersistenceService.cs | 2 + ...ntelligenceVisualisationSnapshotService.cs | 2552 ----------------- ...se24_LeanImportCompletionNotifications.sql | 122 + ...veVisualisationIllustrationAssignments.sql | 10 + .../StoryIntelligenceProgress.cshtml | 5 - .../import-visualisation-archive.md | 107 + .../import-visualisation-preservation.md | 317 ++ .../StoryIntelligenceExperience.cshtml | 0 ...toryIntelligenceExperiencePrototypeData.cs | 0 ...telligenceExperiencePrototypeViewModels.cs | 0 ...IntelligenceIllustrationDiagnostics.cshtml | 0 .../prototype-assets}/asset-bag-wallet.svg | 0 .../prototype-assets}/asset-bench.svg | 0 .../prototype-assets}/asset-book.svg | 0 .../prototype-assets}/asset-bottle.svg | 0 .../prototype-assets}/asset-clock.svg | 0 .../prototype-assets}/asset-document.svg | 0 .../prototype-assets}/asset-envelope.svg | 0 .../prototype-assets}/asset-environment.svg | 0 .../prototype-assets}/asset-generic.svg | 0 .../prototype-assets}/asset-keys.svg | 0 .../prototype-assets}/asset-letter.svg | 0 .../prototype-assets}/asset-notebook.svg | 0 .../prototype-assets}/asset-parcel.svg | 0 .../prototype-assets}/asset-phone.svg | 0 .../prototype-assets}/asset-photo.svg | 0 .../prototype-assets}/asset-red-tr6.svg | 0 .../prototype-assets}/asset-rucksack.svg | 0 .../prototype-assets}/asset-traffic-sign.svg | 0 .../prototype-assets}/character-beth.svg | 0 .../prototype-assets}/character-graham.svg | 0 .../prototype-assets}/character-group.svg | 0 .../prototype-assets}/character-maggie.svg | 0 .../prototype-assets}/character-rosie.svg | 0 .../prototype-assets}/location-bathroom.svg | 0 .../prototype-assets}/location-building.svg | 0 .../prototype-assets}/location-car-park.svg | 0 .../prototype-assets}/location-doweries.svg | 0 .../location-driving-test-centre.svg | 0 .../prototype-assets}/location-flat.svg | 0 .../location-garage-workshop.svg | 0 .../prototype-assets}/location-kitchen.svg | 0 .../prototype-assets}/location-laundry.svg | 0 .../prototype-assets}/location-road.svg | 0 .../prototype-assets}/location-stairwell.svg | 0 .../location-vehicle-interior.svg | 0 .../location-waiting-room.svg | 0 ...tory-intelligence-experience-prototype.css | 0 ...story-intelligence-experience-prototype.js | 0 67 files changed, 773 insertions(+), 4358 deletions(-) delete mode 100644 PlotLine/Controllers/DevelopmentController.cs delete mode 100644 PlotLine/Controllers/StoryIntelligenceVisualisationController.cs delete mode 100644 PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs delete mode 100644 PlotLine/Models/StoryIntelligenceIllustrationMatchingModels.cs delete mode 100644 PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs create mode 100644 PlotLine/Services/StoryIntelligenceOperationalServices.cs delete mode 100644 PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs create mode 100644 PlotLine/Sql/149_Phase24_LeanImportCompletionNotifications.sql create mode 100644 PlotLine/Sql/150_Phase24B_RemoveVisualisationIllustrationAssignments.sql create mode 100644 docs/story-intelligence/import-visualisation-archive.md create mode 100644 docs/story-intelligence/import-visualisation-preservation.md rename {PlotLine/Views/Development => docs/story-intelligence/visualisation-archive/runtime-source}/StoryIntelligenceExperience.cshtml (100%) rename {PlotLine/Services => docs/story-intelligence/visualisation-archive/runtime-source}/StoryIntelligenceExperiencePrototypeData.cs (100%) rename {PlotLine/ViewModels => docs/story-intelligence/visualisation-archive/runtime-source}/StoryIntelligenceExperiencePrototypeViewModels.cs (100%) rename {PlotLine/Views/Development => docs/story-intelligence/visualisation-archive/runtime-source}/StoryIntelligenceIllustrationDiagnostics.cshtml (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-bag-wallet.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-bench.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-book.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-bottle.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-clock.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-document.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-envelope.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-environment.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-generic.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-keys.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-letter.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-notebook.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-parcel.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-phone.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-photo.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-red-tr6.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-rucksack.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/asset-traffic-sign.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/character-beth.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/character-graham.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/character-group.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/character-maggie.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/character-rosie.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-bathroom.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-building.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-car-park.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-doweries.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-driving-test-centre.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-flat.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-garage-workshop.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-kitchen.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-laundry.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-road.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-stairwell.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-vehicle-interior.svg (100%) rename {PlotLine/wwwroot/images/story-intelligence/prototype => docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets}/location-waiting-room.svg (100%) rename {PlotLine/wwwroot/css => docs/story-intelligence/visualisation-archive/runtime-source}/story-intelligence-experience-prototype.css (100%) rename {PlotLine/wwwroot/js => docs/story-intelligence/visualisation-archive/runtime-source}/story-intelligence-experience-prototype.js (100%) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 31f2fc0..a3ec96e 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -10,6 +10,8 @@ using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; +#pragma warning disable CS8321 + var tests = new (string Name, Action Test)[] { ("Malformed confidence 0. is repaired to 0.0", RepairsTrailingDecimalConfidence), @@ -43,10 +45,9 @@ var tests = new (string Name, Action Test)[] ("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely), ("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts), ("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems), - ("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes), - ("Story Intelligence simulation mode remains available", StoryIntelligenceSimulationModeRemainsAvailable), + ("Story Intelligence visualisation archive is preserved", StoryIntelligenceVisualisationArchiveIsPreserved), + ("Story Intelligence visualisation runtime is removed", StoryIntelligenceVisualisationRuntimeIsRemoved), ("Story Intelligence does not retry exhausted billing quota", StoryIntelligenceDoesNotRetryExhaustedBillingQuota), - ("Story Intelligence visualisation contract omits raw manuscript text", StoryIntelligenceVisualisationContractOmitsRawManuscriptText), ("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts), ("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes), ("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel), @@ -59,17 +60,9 @@ var tests = new (string Name, Action Test)[] ("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate), ("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage), ("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes), - ("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity), - ("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene), - ("Phase 21Q repairs production visualisation regressions", Phase21QRepairsProductionVisualisationRegressions), - ("Phase 21Q view keeps panels bounded and real controls hidden", Phase21QViewKeepsPanelsBoundedAndRealControlsHidden), - ("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly), - ("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly), - ("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments), + ("Lean import removes replay links while retaining progress", LeanImportRemovesReplayLinksWhileRetainingProgress), ("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding), ("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures), - ("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules), - ("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific), ("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable), ("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates), ("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords), @@ -82,9 +75,6 @@ var tests = new (string Name, Action Test)[] ("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures), ("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), - ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), - ("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), - ("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence), ("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions), ("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation), ("StoryPosition derives canonical scene context identity", StoryPositionDerivesCanonicalSceneContextIdentity), @@ -957,21 +947,19 @@ static void IllustrationStarterGenerationPlanSkipsSuccessfulItems() Assert(IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(failedAsset.StableCode, [generatedCharacter, failedAsset]), "Failed starter should be eligible."); } -static void StoryIntelligencePrototypeUsesStarterIllustrationCodes() +static void StoryIntelligenceVisualisationArchiveIsPreserved() { - var prototype = StoryIntelligenceExperiencePrototypeData.Build(); - var codes = prototype.Scenes - .SelectMany(scene => scene.Characters.Select(character => character.LibraryCode) - .Concat([scene.Location.LibraryCode]) - .Concat(scene.Assets.Select(asset => asset.LibraryCode))) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + var root = Path.Combine(AppContext.BaseDirectory, "../../../../"); + var archive = Path.Combine(root, "docs/story-intelligence/import-visualisation-archive.md"); + var archiveSource = Path.Combine(root, "docs/story-intelligence/visualisation-archive/runtime-source"); - Assert(codes.Count > 0, "Prototype should carry explicit library codes."); - Assert(codes.All(code => IllustrationStarterBatchDefinition.StarterCodes.Contains(code)), "Prototype requested a stable code that is not in the starter library."); - Assert(prototype.Scenes.SelectMany(scene => scene.Characters).All(character => character.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Character SVG fallback should remain before library resolution."); - Assert(prototype.Scenes.All(scene => scene.Location.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Location SVG fallback should remain before library resolution."); - Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution."); + Assert(File.Exists(archive), "Visualisation archive document should be preserved."); + var text = File.ReadAllText(archive); + Assert(text.Contains("must never cause additional analysis or AI work", StringComparison.OrdinalIgnoreCase), "Archive must record the passive-observer architectural lesson."); + Assert(File.Exists(Path.Combine(archiveSource, "StoryIntelligenceExperience.cshtml")), "Archived Razor markup should be preserved outside runtime."); + Assert(File.Exists(Path.Combine(archiveSource, "story-intelligence-experience-prototype.css")), "Archived CSS should be preserved outside runtime."); + Assert(File.Exists(Path.Combine(archiveSource, "story-intelligence-experience-prototype.js")), "Archived JavaScript should be preserved outside runtime."); + Assert(Directory.Exists(Path.Combine(archiveSource, "prototype-assets")), "Archived prototype assets should be preserved outside runtime."); } static void IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth() @@ -1187,12 +1175,32 @@ static StoryMemoryCharacterAttribute Attribute(string type, string value, decima static IReadOnlyList Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata) => StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false); -static void StoryIntelligenceSimulationModeRemainsAvailable() +static void StoryIntelligenceVisualisationRuntimeIsRemoved() { - var prototype = StoryIntelligenceExperiencePrototypeData.Build(); + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + Assert(!File.Exists(Path.Combine(root, "Controllers/DevelopmentController.cs")), "Development visualisation controller should not be active."); + Assert(!File.Exists(Path.Combine(root, "Controllers/StoryIntelligenceVisualisationController.cs")), "Visualisation snapshot API controller should not be active."); + Assert(!File.Exists(Path.Combine(root, "Views/Development/StoryIntelligenceExperience.cshtml")), "Visualisation Razor view should not be active."); + Assert(!File.Exists(Path.Combine(root, "wwwroot/js/story-intelligence-experience-prototype.js")), "Visualisation JavaScript should not be served from runtime."); + Assert(!File.Exists(Path.Combine(root, "wwwroot/css/story-intelligence-experience-prototype.css")), "Visualisation CSS should not be served from runtime."); + Assert(!Directory.Exists(Path.Combine(root, "wwwroot/images/story-intelligence/prototype")), "Prototype SVG assets should not be served from runtime."); - Assert(prototype.Scenes.Count >= 7, "Simulation should keep the existing multi-scene visual dataset."); - Assert(prototype.Scenes.Any(scene => scene.PovCharacterId == "rosie"), "Simulation should still exercise POV transitions."); + var program = File.ReadAllText(Path.Combine(root, "Program.cs")); + Assert(!program.Contains("IStoryIntelligenceVisualisationSnapshotService", StringComparison.Ordinal), "Visualisation snapshot service should not be registered."); + Assert(!program.Contains("IStoryIntelligenceIllustrationMatchingService", StringComparison.Ordinal), "Visualisation illustration matching adapter should not be registered."); +} + +static void LeanImportRemovesReplayLinksWhileRetainingProgress() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml")); + var progressScript = Path.Combine(root, "wwwroot/js/story-intelligence-progress.js"); + var program = File.ReadAllText(Path.Combine(root, "Program.cs")); + + Assert(!progressView.Contains("Replay Story Intelligence", StringComparison.Ordinal), "Ordinary import progress should not link to replay visualisation."); + Assert(!progressView.Contains("/Development/StoryIntelligenceExperience", StringComparison.Ordinal), "Ordinary import progress should not expose the development visualisation route."); + Assert(File.Exists(progressScript), "Lean import should retain the operational progress script."); + Assert(program.Contains("app.MapHub", StringComparison.Ordinal), "Lean import should retain the shared Story Intelligence progress hub."); } static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota() @@ -1205,31 +1213,7 @@ static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota() static void StoryIntelligenceVisualisationContractOmitsRawManuscriptText() { - var model = new StoryIntelligenceExperiencePrototypeViewModel - { - Mode = "real", - RunId = 42, - ChangeToken = "stable-token", - ProjectName = "Project", - BookTitle = "Book", - Scenes = - [ - new StoryIntelligenceExperienceSceneState - { - Id = "scene-result-1", - SceneNumber = 1, - Title = "Analysed scene", - Summary = "Safe summary", - Location = new StoryIntelligenceExperienceLocationState { Id = "location-result-room", Name = "Room" } - } - ] - }; - - var json = JsonSerializer.Serialize(model, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - Assert(json.Contains("\"changeToken\":\"stable-token\"", StringComparison.Ordinal), "Snapshot contract should expose a change token."); - Assert(!json.Contains("sourceText", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose raw manuscript source text."); - Assert(!json.Contains("rawResponseJson", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose raw AI responses."); - Assert(!json.Contains("prompt", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose prompts."); + Assert(true, "Historical visualisation contract helper retained only to minimise churn after visualisation removal."); } static void IllustrationBulkRetryResultReportsCounts() @@ -1459,42 +1443,7 @@ static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel() static void LiveVisualisationStripsIllustrationDiagnostics() { - var large = new string('x', 200_000); - var model = new StoryIntelligenceExperiencePrototypeViewModel - { - Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics - { - CharacterConsistencyReport = large - }, - Scenes = - [ - new StoryIntelligenceExperienceSceneState - { - Id = "scene-result-1", - Location = new StoryIntelligenceExperienceLocationState(), - Characters = - [ - new StoryIntelligenceExperienceCharacterState - { - Id = "character-result-beth", - Name = "Beth", - ImageResolution = new StoryIntelligenceExperienceImageResolution - { - RejectedCandidates = large, - PreviousEvidence = large, - CurrentEvidence = large, - EvidenceWarnings = large - } - } - ] - } - ] - }; - - StoryIntelligenceVisualisationSnapshotService.StripLiveDiagnostics(model); - var json = JsonSerializer.Serialize(model, JsonOptions()); - Assert(!json.Contains(large, StringComparison.Ordinal), "Live snapshot should not contain large illustration diagnostic strings."); - Assert(json.Length < 20_000, $"Live snapshot remained too large after stripping diagnostics: {json.Length:N0} chars."); + Assert(true, "Historical helper retained only to minimise churn after visualisation removal."); } static void IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence() @@ -1633,11 +1582,9 @@ static void Phase21TRepairsDurableCharacterUnderstanding() Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("pair of girls"), "Pair of girls should not reserve a unique character portrait."); var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs")); - var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs")); Assert(service.Contains("BuildCrossSceneIdentityAliases", StringComparison.Ordinal), "Rebuild should repair cross-scene descriptive-to-named character identities from saved analysis."); Assert(service.Contains("ExtensionEvidence(sceneCharacter.ExtensionData)", StringComparison.Ordinal), "Saved scene appearance extension data must be included in durable evidence."); - Assert(!snapshot.Contains("memoryCharacter", StringComparison.Ordinal), "Visualisation must not carry memory-only characters into scenes without scene evidence."); - Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Live and replay must apply durable Story Memory assignments."); + Assert(!File.Exists(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs")), "Visualisation snapshot service should stay removed."); } static void Phase21UExtractsMandatoryCharacterAppearanceFixtures() diff --git a/PlotLine/Controllers/DevelopmentController.cs b/PlotLine/Controllers/DevelopmentController.cs deleted file mode 100644 index d9fad1f..0000000 --- a/PlotLine/Controllers/DevelopmentController.cs +++ /dev/null @@ -1,193 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using PlotLine.Data; -using PlotLine.Models; -using PlotLine.Services; -using PlotLine.ViewModels; - -namespace PlotLine.Controllers; - -[Authorize(Policy = "AdminOnly")] -[Route("Development")] -public sealed class DevelopmentController( - IWebHostEnvironment environment, - ICurrentUserService currentUser, - IIllustrationLibraryService illustrationLibrary, - IStoryMemoryService storyMemory, - IStoryMemoryRepository storyMemoryRepository, - IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller -{ - [HttpGet("StoryIntelligenceExperience")] - public async Task StoryIntelligenceExperience([FromQuery] string? mode, [FromQuery] int? importSessionId, [FromQuery] int? runId) - { - if (!environment.IsDevelopment()) - { - return NotFound(); - } - - var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim(); - var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase); - var replayRequested = string.Equals(requestedMode, "replay", StringComparison.OrdinalIgnoreCase); - - if (!simulationRequested && importSessionId.HasValue && currentUser.UserId is int userId) - { - var realModel = replayRequested - ? await snapshots.BuildReplaySnapshotAsync(importSessionId.Value, userId) - : await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId); - if (realModel is null) - { - return View(ErrorModel( - requestedMode, - importSessionId, - $"Import Session {importSessionId.Value:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet.")); - } - - return View(realModel); - } - - if (!simulationRequested && runId.HasValue && currentUser.UserId is int legacyUserId) - { - var realModel = await snapshots.BuildSnapshotAsync(runId.Value, legacyUserId); - if (realModel is null) - { - return View(ErrorModel( - requestedMode, - null, - $"Run {runId.Value:N0} could not be found for the current user. Live visualisation URLs should use importSessionId for book imports.")); - } - - return View(realModel); - } - - if (simulationRequested) - { - var model = StoryIntelligenceExperiencePrototypeData.Build(); - await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); - return View(model); - } - - if (currentUser.UserId is int selectorUserId) - { - var sessions = await snapshots.ListImportSessionOptionsAsync(selectorUserId); - return View(new StoryIntelligenceExperiencePrototypeViewModel - { - Mode = "selector", - AvailableRuns = sessions, - ProjectName = "Development", - BookTitle = "Story Intelligence", - SnapshotMessage = sessions.Count == 0 - ? "No persisted Story Intelligence import sessions were found for the current user. Start a real import, then refresh this page." - : "Choose a Story Intelligence import session, or explicitly open simulation mode.", - Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics - { - RequestedMode = requestedMode, - RequestedImportSessionId = importSessionId, - SnapshotSource = "Real" - } - }); - } - - return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in.")); - } - - [HttpPost("StoryIntelligenceExperience/Rebuild")] - [ValidateAntiForgeryToken] - public async Task RebuildStoryIntelligenceExperience( - [FromForm] int importSessionId, - [FromForm] string? rebuildScope, - [FromForm] bool dryRun = false, - [FromForm] bool queueDemand = false, - [FromForm] int? maxDemandToQueue = null, - [FromForm] string? appearancePreference = null) - { - if (!environment.IsDevelopment()) - { - return NotFound(); - } - - if (importSessionId <= 0 || currentUser.UserId is not int userId) - { - return BadRequest("A valid importSessionId is required."); - } - - var rebuild = await storyMemory.RebuildImportSessionAsync( - importSessionId, - userId, - new StoryMemoryRebuildOptions - { - DryRun = dryRun, - RebuildAll = !string.Equals(rebuildScope, "incremental", StringComparison.OrdinalIgnoreCase), - QueueDemand = queueDemand, - MaxDemandToQueue = maxDemandToQueue, - AppearancePreference = string.IsNullOrWhiteSpace(appearancePreference) - ? StoryMemoryAppearancePreferences.Balanced - : appearancePreference.Trim() - }, - HttpContext.RequestAborted); - if (rebuild.DryRun) - { - TempData["StoryIntelligenceReplayMessage"] = $"Dry run complete. {rebuild.SceneResultsRead:N0} stored scene result(s), {rebuild.SceneResultsProcessed:N0} parseable. No database changes, AI calls, or image credits."; - } - else - { - TempData["StoryIntelligenceReplayMessage"] = $"Durable Story Memory rebuilt. {rebuild.SceneResultsProcessed:N0} scene result(s) processed, {rebuild.AssignmentsCreatedOrValidated:N0} assignment(s), {rebuild.DemandsCreated:N0} demand record(s), {rebuild.AiCalls:N0} AI calls."; - } - - return RedirectToAction(nameof(StoryIntelligenceExperience), new { importSessionId, mode = "replay" }); - } - - [HttpGet("StoryMemoryDiagnostics")] - public async Task StoryMemoryDiagnostics([FromQuery] int importSessionId) - { - if (!environment.IsDevelopment()) - { - return NotFound(); - } - - if (importSessionId <= 0) - { - return BadRequest("A valid importSessionId is required."); - } - - var diagnostics = await storyMemoryRepository.GetDiagnosticsAsync(importSessionId); - return Json(diagnostics); - } - - [HttpGet("StoryIntelligenceIllustrationDiagnostics")] - public async Task StoryIntelligenceIllustrationDiagnostics([FromQuery] int importSessionId) - { - if (!environment.IsDevelopment()) - { - return NotFound(); - } - - if (importSessionId <= 0 || currentUser.UserId is not int userId) - { - return BadRequest("A valid importSessionId is required."); - } - - var model = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId); - if (model is null) - { - return NotFound($"Import Session {importSessionId:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet."); - } - - return View(model); - } - - private static StoryIntelligenceExperiencePrototypeViewModel ErrorModel(string requestedMode, int? importSessionId, string message) - => new() - { - Mode = "error", - ProjectName = "Development", - BookTitle = "Story Intelligence", - SnapshotMessage = message, - Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics - { - RequestedMode = requestedMode, - RequestedImportSessionId = importSessionId, - SnapshotSource = "Real", - Warning = message - } - }; -} diff --git a/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs b/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs deleted file mode 100644 index f59054a..0000000 --- a/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using PlotLine.Services; - -namespace PlotLine.Controllers; - -[Authorize] -[Route("api/story-intelligence")] -public sealed class StoryIntelligenceVisualisationController( - ICurrentUserService currentUser, - IStoryIntelligenceVisualisationSnapshotService snapshots, - ILogger logger) : ControllerBase -{ - [HttpGet("runs/{runId:int}/visualisation-snapshot")] - public async Task GetSnapshot(int runId) - { - if (currentUser.UserId is not int userId) - { - return Unauthorized(); - } - - var snapshot = await snapshots.BuildSnapshotAsync(runId, userId); - if (snapshot is not null) - { - var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault(); - logger.LogInformation( - "Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.", - snapshot.ImportSessionId, - snapshot.RunId, - current?.ChapterLabel, - current?.SceneNumber, - snapshot.Diagnostics.SelectedCurrentSceneResultId, - snapshot.ChangeToken); - } - return snapshot is null ? NotFound() : Ok(snapshot); - } - - [HttpGet("import-sessions/{importSessionId:int}/visualisation-snapshot")] - public async Task GetImportSessionSnapshot(int importSessionId) - { - if (currentUser.UserId is not int userId) - { - return Unauthorized(); - } - - var snapshot = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId); - if (snapshot is not null) - { - var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault(); - logger.LogInformation( - "Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.", - snapshot.ImportSessionId, - snapshot.RunId, - current?.ChapterLabel, - current?.SceneNumber, - snapshot.Diagnostics.SelectedCurrentSceneResultId, - snapshot.ChangeToken); - } - return snapshot is null ? NotFound() : Ok(snapshot); - } -} diff --git a/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs b/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs deleted file mode 100644 index 3af455f..0000000 --- a/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs +++ /dev/null @@ -1,117 +0,0 @@ -using Dapper; -using PlotLine.Models; - -namespace PlotLine.Data; - -public interface IStoryIntelligenceIllustrationMatchingRepository -{ - Task> ListAssignmentsAsync(int projectId); - Task UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request); - Task UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request); -} - -public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceIllustrationMatchingRepository -{ - public async Task> ListAssignmentsAsync(int projectId) - { - using var connection = connectionFactory.CreateConnection(); - var rows = await connection.QueryAsync( - """ - SELECT - StoryIntelligenceCharacterIllustrationAssignmentID, - ProjectID, - CharacterKey, - CharacterName, - IllustrationLibraryItemID, - StableCode, - MatchConfidence, - MatchingScore, - CAST(NULL AS nvarchar(max)) AS EvidenceJson, - CAST(NULL AS nvarchar(max)) AS CandidateDiagnosticsJson, - ReasonChosen, - ReasonReassigned, - IsActive, - CreatedUtc, - UpdatedUtc, - AssignmentStatus, - AssignedUtc, - LastValidatedUtc, - EvidenceVersion, - IsFallback - FROM dbo.StoryIntelligenceCharacterIllustrationAssignments - WHERE ProjectID = @ProjectID - AND IsActive = 1; - """, - new { ProjectID = projectId }); - return rows.ToList(); - } - - public async Task UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request) - { - using var connection = connectionFactory.CreateConnection(); - return await connection.QuerySingleAsync( - """ - SET XACT_ABORT ON; - - MERGE dbo.StoryIntelligenceCharacterIllustrationAssignments WITH (HOLDLOCK) AS target - USING (SELECT @ProjectID AS ProjectID, @CharacterKey AS CharacterKey) AS source - ON target.ProjectID = source.ProjectID - AND target.CharacterKey = source.CharacterKey - AND target.IsActive = 1 - WHEN MATCHED THEN - UPDATE SET - CharacterName = @CharacterName, - IllustrationLibraryItemID = @IllustrationLibraryItemID, - StableCode = @StableCode, - MatchConfidence = @MatchConfidence, - MatchingScore = @MatchingScore, - EvidenceJson = @EvidenceJson, - CandidateDiagnosticsJson = @CandidateDiagnosticsJson, - ReasonChosen = @ReasonChosen, - ReasonReassigned = @ReasonReassigned, - AssignmentStatus = CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END, - LastValidatedUtc = SYSUTCDATETIME(), - EvidenceVersion = N'21K', - IsFallback = CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END, - UpdatedUtc = SYSUTCDATETIME() - WHEN NOT MATCHED THEN - INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned, AssignmentStatus, AssignedUtc, LastValidatedUtc, EvidenceVersion, IsFallback) - VALUES (@ProjectID, @CharacterKey, @CharacterName, @IllustrationLibraryItemID, @StableCode, @MatchConfidence, @MatchingScore, @EvidenceJson, @CandidateDiagnosticsJson, @ReasonChosen, @ReasonReassigned, CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END, SYSUTCDATETIME(), SYSUTCDATETIME(), N'21K', CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END); - - SELECT * - FROM dbo.StoryIntelligenceCharacterIllustrationAssignments - WHERE ProjectID = @ProjectID - AND CharacterKey = @CharacterKey - AND IsActive = 1; - """, - request); - } - - public async Task UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request) - { - using var connection = connectionFactory.CreateConnection(); - return await connection.QuerySingleAsync( - """ - SET XACT_ABORT ON; - - MERGE dbo.StoryIntelligenceIllustrationDemands WITH (HOLDLOCK) AS target - USING (SELECT @ProjectID AS ProjectID, @DemandKey AS DemandKey) AS source - ON ISNULL(target.ProjectID, -1) = ISNULL(source.ProjectID, -1) - AND target.DemandKey = source.DemandKey - WHEN MATCHED THEN - UPDATE SET - RequestCount = RequestCount + 1, - LastRequestedUtc = SYSUTCDATETIME(), - UpdatedUtc = SYSUTCDATETIME() - WHEN NOT MATCHED THEN - INSERT (ProjectID, DemandKey, AgeBand, Presentation, HairColour, SkinTone, HairLength, Glasses, FacialHair) - VALUES (@ProjectID, @DemandKey, @AgeBand, @Presentation, @HairColour, @SkinTone, @HairLength, @Glasses, @FacialHair); - - SELECT * - FROM dbo.StoryIntelligenceIllustrationDemands - WHERE ISNULL(ProjectID, -1) = ISNULL(@ProjectID, -1) - AND DemandKey = @DemandKey; - """, - request); - } -} diff --git a/PlotLine/Data/StoryIntelligencePipelineRepository.cs b/PlotLine/Data/StoryIntelligencePipelineRepository.cs index 996b56e..63d31d5 100644 --- a/PlotLine/Data/StoryIntelligencePipelineRepository.cs +++ b/PlotLine/Data/StoryIntelligencePipelineRepository.cs @@ -12,6 +12,7 @@ public interface IStoryIntelligencePipelineRepository Task> ListForUserAsync(int userId); Task UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request); Task> ListCommittedRunsByBookAsync(int bookId, int userId); + Task TryBeginCompletionNotificationAsync(int bookId, int userId); } public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository @@ -81,4 +82,13 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co commandType: CommandType.StoredProcedure); return rows.ToList(); } + + public async Task TryBeginCompletionNotificationAsync(int bookId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceBookPipeline_TryBeginCompletionNotification", + new { BookID = bookId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } } diff --git a/PlotLine/Models/StoryIntelligenceIllustrationMatchingModels.cs b/PlotLine/Models/StoryIntelligenceIllustrationMatchingModels.cs deleted file mode 100644 index 21b5508..0000000 --- a/PlotLine/Models/StoryIntelligenceIllustrationMatchingModels.cs +++ /dev/null @@ -1,70 +0,0 @@ -namespace PlotLine.Models; - -public sealed class StoryIntelligenceCharacterIllustrationAssignment -{ - public int StoryIntelligenceCharacterIllustrationAssignmentID { get; init; } - public int ProjectID { get; init; } - public string CharacterKey { get; init; } = string.Empty; - public string CharacterName { get; init; } = string.Empty; - public int? IllustrationLibraryItemID { get; init; } - public string? StableCode { get; init; } - public decimal MatchConfidence { get; init; } - public int MatchingScore { get; init; } - public string? EvidenceJson { get; init; } - public string? CandidateDiagnosticsJson { get; init; } - public string? ReasonChosen { get; init; } - public string? ReasonReassigned { get; init; } - public int? ImportSessionID { get; init; } - public string AssignmentStatus { get; init; } = "Assigned"; - public DateTime? AssignedUtc { get; init; } - public DateTime? LastValidatedUtc { get; init; } - public DateTime? InvalidatedUtc { get; init; } - public string? InvalidationReason { get; init; } - public string EvidenceVersion { get; init; } = "21K"; - public bool IsFallback { get; init; } - public bool IsActive { get; init; } - public DateTime CreatedUtc { get; init; } - public DateTime UpdatedUtc { get; init; } -} - -public sealed record StoryIntelligenceCharacterIllustrationAssignmentSave( - int ProjectID, - string CharacterKey, - string CharacterName, - int? IllustrationLibraryItemID, - string? StableCode, - decimal MatchConfidence, - int MatchingScore, - string? EvidenceJson, - string? CandidateDiagnosticsJson, - string? ReasonChosen, - string? ReasonReassigned); - -public sealed class StoryIntelligenceIllustrationDemand -{ - public int StoryIntelligenceIllustrationDemandID { get; init; } - public int? ProjectID { get; init; } - public string DemandKey { get; init; } = string.Empty; - public string AgeBand { get; init; } = string.Empty; - public string Presentation { get; init; } = string.Empty; - public string HairColour { get; init; } = string.Empty; - public string SkinTone { get; init; } = string.Empty; - public string? HairLength { get; init; } - public string? Glasses { get; init; } - public string? FacialHair { get; init; } - public int RequestCount { get; init; } - public DateTime LastRequestedUtc { get; init; } - public DateTime? QueuedUtc { get; init; } - public string GenerationSource { get; init; } = "Demand generation"; -} - -public sealed record StoryIntelligenceIllustrationDemandRequest( - int? ProjectID, - string DemandKey, - string AgeBand, - string Presentation, - string HairColour, - string SkinTone, - string? HairLength, - string? Glasses, - string? FacialHair); diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index ed98f20..5a714dd 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -69,6 +69,22 @@ public sealed class StoryIntelligenceBookPipelineState && string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase); } +public sealed class StoryIntelligenceImportNotificationTarget +{ + public int ImportSessionID { get; init; } + public int UserID { get; init; } + public int ProjectID { get; init; } + public string ProjectName { get; init; } = string.Empty; + public int BookID { get; init; } + public string BookTitle { get; init; } = string.Empty; + public string? BookSubtitle { get; init; } + public string BookDisplayTitle => BookTitleFormatter.DisplayTitle(BookTitle, BookSubtitle); + public int ChapterCount { get; init; } + public int CompletedChapterCount { get; init; } + public int FailedChapterCount { get; init; } + public string NotificationKind { get; init; } = "Success"; +} + public sealed class StoryIntelligenceBookPipelineSaveRequest { public int ProjectID { get; init; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 2b1e204..67a823d 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -148,7 +148,6 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -225,12 +224,13 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -238,7 +238,6 @@ public class Program builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/AuthServiceInterfaces.cs b/PlotLine/Services/AuthServiceInterfaces.cs index b1cdecc..5715db1 100644 --- a/PlotLine/Services/AuthServiceInterfaces.cs +++ b/PlotLine/Services/AuthServiceInterfaces.cs @@ -50,6 +50,7 @@ public interface IEmailService Task SendFeatureRequestAdminReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail); Task SendFeatureRequestStatusChangedAsync(FeatureRequest request, string link, string actorEmail); Task SendFeatureRequestUserReplyAsync(FeatureRequest request, string messagePreview, string link, string actorEmail); + Task SendStoryIntelligenceImportNotificationAsync(string email, string displayName, StoryIntelligenceImportNotificationTarget target); } public interface ICurrentUserService diff --git a/PlotLine/Services/AuthServices.cs b/PlotLine/Services/AuthServices.cs index 20d2b93..bb884b4 100644 --- a/PlotLine/Services/AuthServices.cs +++ b/PlotLine/Services/AuthServices.cs @@ -180,6 +180,38 @@ public sealed class EmailService( } } + public Task SendStoryIntelligenceImportNotificationAsync(string email, string displayName, StoryIntelligenceImportNotificationTarget target) + { + var failed = string.Equals(target.NotificationKind, "Failure", StringComparison.OrdinalIgnoreCase); + var subject = failed + ? $"PlotDirector import needs attention: {target.BookDisplayTitle}" + : $"PlotDirector import complete: {target.BookDisplayTitle}"; + var statusLine = failed + ? "The manuscript import has stopped with one or more chapters needing attention." + : "The manuscript import has finished and is ready for review."; + var actionLine = failed + ? "Please open PlotDirector to review the failed chapter analysis before continuing." + : "Please open PlotDirector to review and commit the imported scenes."; + + var body = $""" + Hi {DisplayName(displayName)}, + + {statusLine} + + Project: {target.ProjectName} + Book: {target.BookDisplayTitle} + Chapters processed: {target.CompletedChapterCount:N0} of {target.ChapterCount:N0} + Failed chapters: {target.FailedChapterCount:N0} + + {actionLine} + + Thanks, + The PlotDirector team + """; + + return QueueAsync(email, DisplayName(displayName), subject, body, html: null); + } + private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string? html) { if (string.IsNullOrWhiteSpace(toEmail)) diff --git a/PlotLine/Services/IllustrationLibraryServices.cs b/PlotLine/Services/IllustrationLibraryServices.cs index d604727..6d9a0d0 100644 --- a/PlotLine/Services/IllustrationLibraryServices.cs +++ b/PlotLine/Services/IllustrationLibraryServices.cs @@ -649,14 +649,12 @@ public interface IIllustrationLibraryService Task RegenerateAsync(int illustrationLibraryItemId, bool confirmed); Task DisableAsync(int illustrationLibraryItemId); Task UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form); - Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype); } public sealed class IllustrationLibraryService( IIllustrationLibraryRepository repository, IIllustrationPromptBuilder promptBuilder, - IIllustrationImageProvider provider, - IUploadStorageService uploadStorage) : IIllustrationLibraryService + IIllustrationImageProvider provider) : IIllustrationLibraryService { private const int MaxGenerationAttempts = 3; @@ -953,115 +951,4 @@ public sealed class IllustrationLibraryService( return new(true, "Illustration metadata updated."); } - public async Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype) - { - var items = await repository.ListAsync(new IllustrationLibraryFilter()); - var lookup = items - .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) - .Where(item => item.IsActive && !item.IsArchived) - .Where(item => !item.IsFallbackClassification) - .OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1) - .ThenByDescending(item => item.UpdatedUtc) - .GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); - - foreach (var scene in prototype.Scenes) - { - var locationFallback = scene.Location.ImagePath; - var locationResolution = ResolvePrototypeImage(scene.Location.Id, scene.Location.LibraryCode, locationFallback, lookup); - scene.Location.ImageResolution = locationResolution; - scene.Location.ImagePath = locationResolution.FinalImageUrl ?? locationFallback; - - foreach (var character in scene.Characters) - { - var fallback = character.ImagePath; - var resolution = ResolvePrototypeImage(character.Id, character.LibraryCode, fallback, lookup); - character.ImageResolution = resolution; - character.ImagePath = resolution.FinalImageUrl ?? fallback; - } - - foreach (var asset in scene.Assets) - { - var fallback = asset.ImagePath; - var resolution = ResolvePrototypeImage(asset.Id, asset.LibraryCode, fallback, lookup); - asset.ImageResolution = resolution; - asset.ImagePath = resolution.FinalImageUrl ?? fallback; - } - } - } - - private StoryIntelligenceExperienceImageResolution ResolvePrototypeImage( - string entityId, - string stableCode, - string fallbackImageUrl, - IReadOnlyDictionary> lookup) - { - if (string.IsNullOrWhiteSpace(stableCode) || !lookup.TryGetValue(stableCode, out var candidates)) - { - return PrototypeFallback(entityId, stableCode, fallbackImageUrl); - } - - foreach (var candidate in candidates) - { - var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath); - if (publicUrl is null) - { - continue; - } - - return new() - { - EntityId = entityId, - StableCode = stableCode, - IllustrationLibraryItemId = candidate.IllustrationLibraryItemID, - Status = candidate.Status, - FinalImageUrl = publicUrl, - FallbackImageUrl = fallbackImageUrl, - FallbackUsed = false - }; - } - - var firstCandidate = candidates.FirstOrDefault(); - return new() - { - EntityId = entityId, - StableCode = stableCode, - IllustrationLibraryItemId = firstCandidate?.IllustrationLibraryItemID, - Status = firstCandidate?.Status, - FallbackImageUrl = fallbackImageUrl, - FallbackUsed = true - }; - } - - private string? ResolveExistingPublicUploadUrl(string? storedPath) - { - var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library"); - if (physicalPath is null || !File.Exists(physicalPath)) - { - return null; - } - - var normalised = storedPath!.Replace('\\', '/').Trim(); - if (!normalised.StartsWith('/')) - { - normalised = "/" + normalised.TrimStart('/'); - } - - return EncodeUrlPath(normalised); - } - - private static StoryIntelligenceExperienceImageResolution PrototypeFallback(string entityId, string stableCode, string fallbackImageUrl) - => new() - { - EntityId = entityId, - StableCode = stableCode, - FallbackImageUrl = fallbackImageUrl, - FallbackUsed = true - }; - - private static string EncodeUrlPath(string path) - => string.Join( - '/', - path.Split('/', StringSplitOptions.None) - .Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment))); } diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 7df25bf..407762d 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -45,6 +45,7 @@ public sealed class OnboardingStoryIntelligenceService( IOnboardingStoryIntelligenceBatchStore batchStore, IStoryIntelligenceProgressNotifier notifier, ICurrentUserService currentUser, + IStoryIntelligenceEntitlementService entitlement, ILogger logger) : IOnboardingStoryIntelligenceService { private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2"; @@ -93,6 +94,7 @@ public sealed class OnboardingStoryIntelligenceService( public async Task StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null) { var userId = RequireUserId(); + await entitlement.EnsureCanQueueAsync(userId); var context = await GetContextAsync(userId); if (context is null) { diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 6295b84..2da1620 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -25,6 +25,8 @@ public sealed class PersistedStoryIntelligenceRunner( IStoryIntelligenceProgressNotifier notifier, IStoryMemoryService storyMemory, ICharacterIntelligenceService characterIntelligence, + IStoryIntelligenceEntitlementService entitlement, + IStoryIntelligenceCompletionNotificationService completionNotifications, ILogger logger) : IPersistedStoryIntelligenceRunner { private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md"; @@ -80,6 +82,8 @@ public sealed class PersistedStoryIntelligenceRunner( "Story Intelligence run has no source text."); } + await entitlement.EnsureCanExecuteAsync(run.UserID); + await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken); var paragraphs = StoryIntelligenceParagraphs.Split(run.SourceText); if (paragraphs.Count == 0) @@ -462,6 +466,7 @@ public sealed class PersistedStoryIntelligenceRunner( completedScenes: completedScenes, failedScenes: failedScenes, totalTokens: totals.TotalTokens); + await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken); } catch (StoryIntelligenceRunCancelledException) { @@ -490,6 +495,7 @@ public sealed class PersistedStoryIntelligenceRunner( "Analysis could not finish.", stopwatch.ElapsedMilliseconds, errorMessage: ex.Message); + await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken); } catch (StoryIntelligenceRunFailureException ex) { @@ -502,6 +508,7 @@ public sealed class PersistedStoryIntelligenceRunner( "Analysis could not finish.", stopwatch.ElapsedMilliseconds, errorMessage: ex.Message); + await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -519,6 +526,7 @@ public sealed class PersistedStoryIntelligenceRunner( "Analysis could not finish.", stopwatch.ElapsedMilliseconds, errorMessage: ex.Message); + await completionNotifications.NotifyIfImportTerminalAsync(run, cancellationToken); } } diff --git a/PlotLine/Services/StoryIntelligenceDevelopmentResetService.cs b/PlotLine/Services/StoryIntelligenceDevelopmentResetService.cs index 583e20b..a4869bc 100644 --- a/PlotLine/Services/StoryIntelligenceDevelopmentResetService.cs +++ b/PlotLine/Services/StoryIntelligenceDevelopmentResetService.cs @@ -144,9 +144,12 @@ WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = St DELETE FROM dbo.StoryMemoryImportPreferences WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryImportPreferences.ImportSessionID); -DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments -WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID) - OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceCharacterIllustrationAssignments.ProjectID); +IF OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'U') IS NOT NULL +BEGIN + DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments + WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID) + OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceCharacterIllustrationAssignments.ProjectID); +END; DELETE FROM dbo.StoryIntelligenceIllustrationDemands WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceIllustrationDemands.ProjectID); diff --git a/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs b/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs index 6c7e416..93e53cc 100644 --- a/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs +++ b/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs @@ -14,6 +14,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService( IStoryIntelligenceResultRepository runs, IStoryIntelligenceClient client, IStoryIntelligencePipelineStateService pipelines, + IStoryIntelligenceEntitlementService entitlement, ILogger logger) : IStoryIntelligenceExistingChapterQueueService { private const string NoStoredChapterTextMessage = "No stored chapter text is currently available for this chapter. A future Word Companion/document extraction phase is required."; @@ -26,6 +27,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService( public async Task QueueAsync(int userId, StoryIntelligenceExistingChapterQueueForm form) { + await entitlement.EnsureCanQueueAsync(userId); if (form.ChapterID is not int chapterId) { return await BuildViewModelAsync( diff --git a/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs b/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs index 22682d4..1874a88 100644 --- a/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs +++ b/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs @@ -19,6 +19,7 @@ public sealed class StoryIntelligenceFullChapterTestService( IStoryIntelligencePipelineStateService pipelines, IOptions options, IOptions pricingOptions, + IStoryIntelligenceEntitlementService entitlement, ILogger logger) : IStoryIntelligenceFullChapterTestService { private const int LargeChapterCharacterThreshold = 40000; @@ -74,6 +75,7 @@ public sealed class StoryIntelligenceFullChapterTestService( public async Task QueueAsync(int userId, StoryIntelligenceFullChapterTestForm form, IFormFile? textFile) { + await entitlement.EnsureCanQueueAsync(userId); var source = await ReadSourceAsync(form, textFile); if (!string.IsNullOrWhiteSpace(source.ErrorMessage)) { diff --git a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs b/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs deleted file mode 100644 index 5395951..0000000 --- a/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs +++ /dev/null @@ -1,1144 +0,0 @@ -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Security.Cryptography; -using System.Text; -using Microsoft.Extensions.Options; -using PlotLine.Data; -using PlotLine.Models; -using PlotLine.ViewModels; - -namespace PlotLine.Services; - -public interface IStoryIntelligenceIllustrationMatchingService -{ - Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype); - Task ApplyPersistedCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype); - Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype); -} - -public sealed class StoryIntelligenceIllustrationMatchingService( - IIllustrationLibraryRepository library, - IStoryIntelligenceIllustrationMatchingRepository assignments, - IIllustrationPromptBuilder promptBuilder, - IUploadStorageService uploadStorage, - IOptions options, - ILogger logger) : IStoryIntelligenceIllustrationMatchingService -{ - private const int SuitableScore = 48; - private const int ReassignmentMargin = 24; - private const int DemandGenerationThreshold = 1; - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - private readonly StoryIntelligenceOptions settings = options.Value; - - public async Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype) - { - if (projectId <= 0 || prototype.Scenes.Count == 0) - { - return; - } - - var catalogue = (await library.ListAsync(new IllustrationLibraryFilter { Category = IllustrationLibraryCategories.Character })) - .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) - .Where(item => item.IsActive && !item.IsArchived) - .Where(item => !item.IsFallbackClassification) - .Select(CharacterCandidate.From) - .Where(candidate => candidate is not null) - .Cast() - .Select(candidate => candidate with { PublicUrl = ResolveExistingPublicUploadUrl(candidate.Item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.Item.FilePath) }) - .Where(candidate => !string.IsNullOrWhiteSpace(candidate.PublicUrl)) - .ToList(); - - if (catalogue.Count == 0) - { - logger.LogWarning("No generated character illustrations are available for Story Intelligence matching."); - return; - } - - var existingAssignments = (await assignments.ListAssignmentsAsync(projectId)) - .ToDictionary(item => item.CharacterKey, StringComparer.OrdinalIgnoreCase); - var assignedItemIds = existingAssignments.Values - .Where(item => item.IllustrationLibraryItemID.HasValue) - .Select(item => item.IllustrationLibraryItemID!.Value) - .ToHashSet(); - var duplicateExistingItemIds = existingAssignments.Values - .Where(item => item.IllustrationLibraryItemID.HasValue) - .GroupBy(item => item.IllustrationLibraryItemID!.Value) - .Where(group => group.Select(item => item.CharacterKey).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1) - .Select(group => group.Key) - .ToHashSet(); - var retainedExistingItemIds = new HashSet(); - var observations = BuildCharacterObservations(prototype); - - foreach (var observation in observations.Values.OrderByDescending(item => item.Significance).ThenBy(item => item.Name)) - { - if (StoryIntelligenceIllustrationCompatibility.IsGroupEntity(observation.Name)) - { - ApplyGroupDiagnostics(prototype, observation); - continue; - } - - var existing = existingAssignments.GetValueOrDefault(observation.CharacterKey); - var ownExistingItemId = existing?.IllustrationLibraryItemID; - if (ownExistingItemId.HasValue && duplicateExistingItemIds.Contains(ownExistingItemId.Value) && !retainedExistingItemIds.Add(ownExistingItemId.Value)) - { - ownExistingItemId = null; - existing = null; - } - - var allScores = catalogue - .Select(candidate => Score( - candidate, - observation, - assignedItemIds.Contains(candidate.Item.IllustrationLibraryItemID) - && candidate.Item.IllustrationLibraryItemID != ownExistingItemId)) - .OrderByDescending(score => score.Score) - .ThenBy(score => score.IsAlreadyAssignedToAnotherSignificantCharacter) - .ThenByDescending(score => score.Candidate.Item.Status == IllustrationLibraryStatuses.Approved) - .ThenByDescending(score => score.Candidate.Item.UpdatedUtc) - .ToList(); - - var existingScore = existing?.IllustrationLibraryItemID is int existingItemId - ? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId) - : null; - var best = allScores - .Where(score => score.Rejections.Count == 0) - .FirstOrDefault(score => score.Score >= SuitableScore); - - var chosen = ChooseAssignment(observation, existing, existingScore, best); - if (chosen is null) - { - var demandStatus = await RecordDemandAsync(projectId, observation, allScores); - var fallbackCandidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions); - var fallbackEvidenceJson = EvidenceTraceJson(observation, existing); - await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave( - projectId, - observation.CharacterKey, - observation.Name, - null, - null, - 0, - 0, - fallbackEvidenceJson, - fallbackCandidateDiagnostics, - "No hard-compatible unused illustration exists; persisted neutral fallback and recorded library demand.", - null)); - ApplyDemandDiagnostics(prototype, observation, allScores, demandStatus); - continue; - } - - var reasonReassigned = existingScore is not null && chosen.Candidate.Item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID - ? ReassignmentReason(observation, existingScore, chosen) - : existing is { IllustrationLibraryItemID: null } - ? $"Assigned {chosen.Candidate.Item.StableCode} because generated demand or newly valid library art replaced a fallback assignment." - : null; - var reason = reasonReassigned ?? (existing is null - ? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence." - : $"Retained {chosen.Candidate.Item.StableCode}; existing assignment passed hard revalidation with score {chosen.Score:N0}."); - var candidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions); - var evidenceJson = EvidenceTraceJson(observation, existing); - - var saved = await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave( - projectId, - observation.CharacterKey, - observation.Name, - chosen.Candidate.Item.IllustrationLibraryItemID, - chosen.Candidate.Item.StableCode, - Confidence(chosen.Score), - chosen.Score, - evidenceJson, - candidateDiagnostics, - reason, - reasonReassigned)); - - assignedItemIds.Add(chosen.Candidate.Item.IllustrationLibraryItemID); - ApplyAssignment(prototype, observation, existing, chosen, saved, reason, reasonReassigned, evidenceJson, candidateDiagnostics); - } - } - - public async Task ApplyPersistedCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype) - { - if (projectId <= 0 || prototype.Scenes.Count == 0) - { - return; - } - - var existingAssignments = (await assignments.ListAssignmentsAsync(projectId)) - .Where(item => item.IllustrationLibraryItemID.HasValue && !string.IsNullOrWhiteSpace(item.StableCode)) - .ToDictionary(item => item.CharacterKey, StringComparer.OrdinalIgnoreCase); - if (existingAssignments.Count == 0) - { - return; - } - - var catalogue = (await library.ListAsync(new IllustrationLibraryFilter { Category = IllustrationLibraryCategories.Character })) - .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) - .Where(item => item.IsActive && !item.IsArchived) - .Where(item => !item.IsFallbackClassification) - .Where(item => !string.IsNullOrWhiteSpace(item.StableCode)) - .GroupBy(item => item.IllustrationLibraryItemID) - .ToDictionary(group => group.Key, group => group.First()); - - foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters)) - { - if (!existingAssignments.TryGetValue(character.Id, out var assignment) - || assignment.IllustrationLibraryItemID is not int itemId - || !catalogue.TryGetValue(itemId, out var item)) - { - continue; - } - - var publicUrl = ResolveExistingPublicUploadUrl(item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(item.FilePath); - if (string.IsNullOrWhiteSpace(publicUrl)) - { - continue; - } - - var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl) - ? character.ImagePath - : character.ImageResolution.FallbackImageUrl; - character.LibraryCode = item.StableCode; - character.ImagePath = publicUrl; - character.ImageResolution = new StoryIntelligenceExperienceImageResolution - { - EntityId = character.Id, - StableCode = item.StableCode, - IllustrationLibraryItemId = item.IllustrationLibraryItemID, - Status = item.Status, - FinalImageUrl = publicUrl, - FallbackImageUrl = fallbackImageUrl, - FallbackUsed = false, - AssignmentConfidence = assignment.MatchConfidence, - MatchingScore = assignment.MatchingScore, - ReasonChosen = FirstConfigured(assignment.ReasonChosen, "Replay used persisted character illustration assignment."), - ReasonReassigned = assignment.ReasonReassigned, - PreviousIllustration = assignment.StableCode, - DemandStatus = "Replay read-only; demand generation disabled", - IllustrationAllocationStatus = "Persisted replay assignment" - }; - } - } - - public async Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype) - { - if (projectId <= 0 || prototype.Scenes.Count == 0) - { - return; - } - - var items = await library.ListAsync(new IllustrationLibraryFilter()); - var lookup = items - .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated) - .Where(item => item.IsActive && !item.IsArchived) - .Where(item => !item.IsFallbackClassification) - .GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); - - foreach (var location in prototype.Scenes.Select(scene => scene.Location)) - { - await ApplyLocationDemandAsync(projectId, location, lookup); - } - - foreach (var asset in prototype.Scenes.SelectMany(scene => scene.Assets)) - { - await ApplyAssetDemandAsync(projectId, asset, lookup); - } - } - - private async Task ApplyLocationDemandAsync( - int projectId, - StoryIntelligenceExperienceLocationState location, - IReadOnlyDictionary> lookup) - { - var type = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.Name} {location.Label}"); - if (type is "UnknownInterior") - { - location.ImageResolution.ReasonChosen ??= "Location type is unresolved; neutral location fallback retained and no finished unrelated artwork assigned."; - location.ImageResolution.DemandStatus ??= "Not queued until location type resolves"; - return; - } - - if (!location.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(location.ImageResolution.FinalImageUrl)) - { - return; - } - - var demandKey = $"location|{type}".ToLowerInvariant(); - var code = $"loc-demand-{StableHash(demandKey)}"; - location.LibraryCode = code; - - if (TryApplyGeneratedSupportingImage(location.Id, code, location.ImagePath, lookup, out var resolution)) - { - location.ImageResolution = WithDemandDiagnostics( - resolution, - demandKey, - "Satisfied by generated demand illustration", - $"Applied generated {type} location illustration from demand key {demandKey}."); - location.ImagePath = resolution.FinalImageUrl ?? location.ImagePath; - return; - } - - var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest( - projectId, - demandKey, - "Unknown", - "Unknown", - "Unknown", - "Unknown", - "Unknown", - "Unknown", - "Unknown")); - var queued = ShouldQueueAutomaticDemand() - && StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateLocation(type) - && await QueueDemandSpecAsync(LocationDemandSpecification(code, demandKey, type)); - location.ImageResolution = WithDemandDiagnostics( - location.ImageResolution, - demandKey, - queued - ? $"Queued missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})" - : $"Demand recorded for missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})", - $"No generated compatible {type} location illustration exists; neutral typed fallback retained while reusable demand is generated.", - code, - queued ? "DemandQueued" : "DemandRecorded"); - } - - private async Task ApplyAssetDemandAsync( - int projectId, - StoryIntelligenceExperienceAssetState asset, - IReadOnlyDictionary> lookup) - { - var evidenceText = $"{asset.Name} {asset.Label}"; - var type = StoryIntelligenceIllustrationCompatibility.AssetType(evidenceText); - if (type is "UnknownObject") - { - asset.ImageResolution.ReasonChosen ??= "Asset type is unresolved; neutral asset fallback retained and no unrelated finished artwork assigned."; - asset.ImageResolution.DemandStatus ??= "Not queued until asset type resolves"; - return; - } - - if (!asset.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(asset.ImageResolution.FinalImageUrl)) - { - return; - } - - var colour = StoryIntelligenceIllustrationCompatibility.AssetColour(evidenceText); - var demandKey = $"asset|{type}|{colour}".ToLowerInvariant(); - var code = $"asset-demand-{StableHash(demandKey)}"; - asset.LibraryCode = code; - - if (TryApplyGeneratedSupportingImage(asset.Id, code, asset.ImagePath, lookup, out var resolution)) - { - asset.ImageResolution = WithDemandDiagnostics( - resolution, - demandKey, - "Satisfied by generated demand illustration", - $"Applied generated {type} asset illustration from demand key {demandKey}."); - asset.ImagePath = resolution.FinalImageUrl ?? asset.ImagePath; - return; - } - - var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest( - projectId, - demandKey, - "Unknown", - "Unknown", - "Unknown", - "Unknown", - "Unknown", - "Unknown", - "Unknown")); - var queued = ShouldQueueAutomaticDemand() - && StoryIntelligenceIllustrationCompatibility.CanAutomaticallyGenerateAsset(type) - && await QueueDemandSpecAsync(AssetDemandSpecification(code, demandKey, type, colour)); - asset.ImageResolution = WithDemandDiagnostics( - asset.ImageResolution, - demandKey, - queued - ? $"Queued missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})" - : $"Demand recorded for missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})", - $"No generated compatible {type} asset illustration exists; neutral typed fallback retained while reusable demand is generated.", - code, - queued ? "DemandQueued" : "DemandRecorded"); - } - - private bool TryApplyGeneratedSupportingImage( - string entityId, - string code, - string fallbackImageUrl, - IReadOnlyDictionary> lookup, - out StoryIntelligenceExperienceImageResolution resolution) - { - if (lookup.TryGetValue(code, out var candidates)) - { - foreach (var candidate in candidates) - { - var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath); - if (publicUrl is null) - { - continue; - } - - resolution = new StoryIntelligenceExperienceImageResolution - { - EntityId = entityId, - StableCode = code, - IllustrationLibraryItemId = candidate.IllustrationLibraryItemID, - Status = candidate.Status, - FinalImageUrl = publicUrl, - FallbackImageUrl = fallbackImageUrl, - FallbackUsed = false - }; - return true; - } - } - - resolution = new StoryIntelligenceExperienceImageResolution(); - return false; - } - - private static StoryIntelligenceExperienceImageResolution WithDemandDiagnostics( - StoryIntelligenceExperienceImageResolution source, - string demandKey, - string demandStatus, - string reasonChosen, - string? stableCode = null, - string? status = null) - => new() - { - EntityId = source.EntityId, - StableCode = stableCode ?? source.StableCode, - IllustrationLibraryItemId = source.IllustrationLibraryItemId, - Status = status ?? source.Status, - FinalImageUrl = source.FinalImageUrl, - FallbackImageUrl = source.FallbackImageUrl, - FallbackUsed = source.FallbackUsed, - AssignmentConfidence = source.AssignmentConfidence, - MatchingScore = source.MatchingScore, - RejectedCandidates = source.RejectedCandidates, - ReasonChosen = reasonChosen, - ReasonReassigned = source.ReasonReassigned, - PreviousIllustration = source.PreviousIllustration, - PreviousEvidence = source.PreviousEvidence, - CurrentEvidence = source.CurrentEvidence, - DemandKey = demandKey, - EvidenceWarnings = source.EvidenceWarnings, - DemandStatus = demandStatus, - ResolvedPresentation = source.ResolvedPresentation, - ResolvedAgeBand = source.ResolvedAgeBand, - RelationshipDerivedConfidence = source.RelationshipDerivedConfidence, - NameDerivedConfidence = source.NameDerivedConfidence, - PronounDerivedConfidence = source.PronounDerivedConfidence, - IllustrationReuseCount = source.IllustrationReuseCount, - IllustrationAllocationStatus = source.IllustrationAllocationStatus - }; - - private async Task QueueDemandSpecAsync(IllustrationGenerationSpecification specification) - { - var prompt = promptBuilder.Build(specification); - var item = await library.UpsertPlannedAsync(specification, prompt); - if (item.Status is IllustrationLibraryStatuses.Queued or IllustrationLibraryStatuses.Generating or IllustrationLibraryStatuses.Generated or IllustrationLibraryStatuses.Approved) - { - return false; - } - - return await library.QueueAsync(item.IllustrationLibraryItemID, null) > 0; - } - - private bool ShouldQueueAutomaticDemand() - => settings.AutomaticImageGenerationDuringImportEnabled - && !settings.RequireImageGenerationApproval - && settings.MaxImagesPerImport > 0; - - private MatchScore? ChooseAssignment( - CharacterObservation observation, - StoryIntelligenceCharacterIllustrationAssignment? existing, - MatchScore? existingScore, - MatchScore? best) - { - if (existingScore is not null && existingScore.Candidate.PublicUrl is not null) - { - if (existingScore.Rejections.Count > 0) - { - return best; - } - - if (best is null) - { - return existingScore.Score >= SuitableScore ? existingScore : null; - } - - var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence); - if (materiallyWrong) - { - return best; - } - - if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin) - { - return existingScore.Score >= SuitableScore ? existingScore : best; - } - } - - if (best is not null) - { - return best; - } - - return existing is null ? null : existingScore; - } - - private static bool MateriallyWrong(CharacterIllustrationMetadata candidate, CharacterEvidence evidence) - => KnownMismatch(evidence.AgeBand, candidate.AgeBand) - || KnownMismatch(evidence.Presentation, candidate.Presentation) - || KnownMismatch(evidence.HairColour, candidate.HairColour) - || KnownMismatch(evidence.SkinTone, candidate.SkinTone); - - private static bool KnownMismatch(string value, string candidate) - => !IsUnknown(value) && !IsUnknown(candidate) && !string.Equals(value, candidate, StringComparison.OrdinalIgnoreCase); - - private async Task RecordDemandAsync(int projectId, CharacterObservation observation, IReadOnlyList allScores) - { - var evidence = observation.Evidence; - var request = new StoryIntelligenceIllustrationDemandRequest( - projectId, - DemandKey(evidence), - evidence.AgeBand, - evidence.Presentation, - evidence.HairColour, - evidence.SkinTone, - evidence.HairLength, - evidence.Glasses, - evidence.FacialHair); - var demand = await assignments.UpsertDemandAsync(request); - var status = $"Demand recorded ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})"; - if (demand.RequestCount >= DemandGenerationThreshold) - { - var specification = DemandSpecification(demand); - var prompt = promptBuilder.Build(specification); - var item = await library.UpsertPlannedAsync(specification, prompt); - var queued = item.Status == IllustrationLibraryStatuses.Planned - ? await library.QueueAsync(item.IllustrationLibraryItemID, null) - : 0; - status = queued > 0 - ? $"Demand threshold reached; queued {item.StableCode}" - : $"Demand threshold reached; {item.StableCode} already active"; - } - - logger.LogInformation( - "Recorded Story Intelligence illustration demand {DemandKey} for project {ProjectId}. Request count {RequestCount}. Best score {BestScore}. Status {DemandStatus}.", - demand.DemandKey, - projectId, - demand.RequestCount, - allScores.FirstOrDefault()?.Score ?? 0, - status); - return status; - } - - private static string DemandKey(CharacterEvidence evidence) - => string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant(); - - private static string EvidenceTraceJson(CharacterObservation observation, StoryIntelligenceCharacterIllustrationAssignment? existing) - => JsonSerializer.Serialize(new - { - character = observation.Name, - identityKey = observation.CharacterKey, - canonicalIdentity = observation.CharacterKey, - rawExtractedEvidence = CompactEvidence(observation.IdentityEvidence), - sceneEvidence = CompactEvidence(observation.TextEvidence), - relationshipEvidence = CompactEvidence(observation.RelationshipEvidence), - mergedEvidence = CompactEvidence(observation.TextEvidence.Concat(observation.RelationshipEvidence)), - previousAssignment = existing is null - ? null - : new - { - existing.StableCode, - existing.IllustrationLibraryItemID, - existing.MatchConfidence, - existing.MatchingScore, - evidenceChars = existing.EvidenceJson?.Length ?? 0, - evidenceHash = string.IsNullOrWhiteSpace(existing.EvidenceJson) ? null : StableHash(existing.EvidenceJson) - }, - finalMatcherEvidence = observation.Evidence, - evidenceSource = "Story Intelligence visualisation character state", - canonicalMergeReason = "Character state ID from canonical visualisation identity" - }, JsonOptions); - - private static IReadOnlyList CompactEvidence(IEnumerable evidence) - => evidence - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(value => Trim(value, 160)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(16) - .ToList(); - - private static string Trim(string? value, int maxLength) - { - if (string.IsNullOrWhiteSpace(value)) - { - return string.Empty; - } - - var clean = Regex.Replace(value.Trim(), @"\s+", " "); - return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd() + "..."; - } - - private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand) - { - var age = NormaliseUnknown(demand.AgeBand, "YoungAdult"); - var presentation = NormaliseUnknown(demand.Presentation, "Androgynous"); - var hairColour = NormaliseUnknown(demand.HairColour, "Brown"); - var hairLength = NormaliseUnknown(demand.HairLength, "Medium"); - var skinTone = NormaliseUnknown(demand.SkinTone, BalancedSkinToneFallback(demand.DemandKey)); - var features = new List { "DemandGenerated" }; - if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase)) - { - features.Add("Glasses"); - } - - if (!IsUnknown(demand.FacialHair)) - { - features.Add(demand.FacialHair!); - } - - return new IllustrationGenerationSpecification - { - Category = IllustrationLibraryCategories.Character, - Code = $"char-demand-{StableHash(demand.DemandKey)}", - Title = $"{age} {presentation.ToLowerInvariant()} character", - Description = "Demand-created reusable character illustration for Story Intelligence matching.", - VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {SkinToneInstruction(skinTone)}. The face must read immediately as {PresentationPrompt(presentation)} with distinct facial structure, expression, jaw, eyes, nose and hairstyle silhouette.", - Composition = "clear head-and-shoulders cinematic portrait with readable face, subtle atmospheric story background, clean separation, suitable for circular cropping, visibly different from other reusable portraits", - Mood = "neutral, story-ready, emotionally readable, subdued and cinematic", - ApparentAgeBand = age, - Presentation = presentation, - HairColour = hairColour, - HairLength = hairLength, - SkinTone = skinTone, - ClothingEra = "Contemporary", - ClothingStyle = "Understated reusable story wardrobe, not studio fashion styling", - Features = features, - Palette = ["dark navy atmosphere", "warm skin tones", "restrained amber accent"], - MetadataTags = ["character", "demand-generated", "story-intelligence"], - Metadata = new(StringComparer.OrdinalIgnoreCase) - { - ["libraryPurpose"] = "Story Intelligence demand-generated identifier", - ["privacy"] = "generic-no-manuscript-data", - ["style"] = "editorial-stylised-realism", - ["styleIntent"] = "match-approved-starter-library", - ["generationSource"] = "Demand generation", - ["demandKey"] = demand.DemandKey, - ["requestCount"] = demand.RequestCount.ToString("N0") - } - }; - } - - private static IllustrationGenerationSpecification LocationDemandSpecification(string code, string demandKey, string type) - { - var exteriorTransport = type is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" or "DualCarriageway" or "Motorway" or "Bridge" or "Flyover" or "Underpass"; - var publicToilet = type is "PublicToilet"; - var readableType = ReadableToken(type).ToLowerInvariant(); - return new() - { - Category = IllustrationLibraryCategories.Location, - Code = code, - Title = $"{ReadableToken(type)} location", - Description = "Demand-created reusable location illustration for Story Intelligence matching.", - VisualSubject = exteriorTransport - ? $"A clear reusable exterior {readableType} road environment suitable for manuscript visualisation, with visible road surface, kerbs or verges, exterior daylight or street lighting, and no indoor corridor, hallway, doors-lined passage or building interior." - : publicToilet - ? "A clear reusable public toilet or pub/bar washroom interior with cubicles or sinks, commercial fixtures, practical lighting, no domestic bath, no home bathroom styling." - : $"A clear reusable {readableType} environment suitable for manuscript visualisation.", - Composition = exteriorTransport - ? "single readable exterior transport environment, road type obvious at thumbnail size, no prominent people, no readable text, not an indoor corridor" - : publicToilet - ? "single readable public washroom environment, cubicles or sink area obvious at thumbnail size, no bath, no domestic home decor, no prominent people" - : "single readable environment, no prominent people, strong central focal point, suitable for circular and wide crops", - Mood = "story-ready, atmospheric but clear, visually specific rather than generic", - LocationType = type, - Era = "Contemporary or late twentieth century", - Setting = exteriorTransport || type is "CarPark" or "HouseExterior" or "Beach" or "Canal" or "StreetFurniture" ? "Exterior" : "Interior", - Palette = ["clear blue", "warm practical light", "balanced neutral accents"], - MetadataTags = ["location", "demand-generated", "story-intelligence"], - Metadata = new(StringComparer.OrdinalIgnoreCase) - { - ["libraryPurpose"] = "Story Intelligence demand-generated location", - ["privacy"] = "generic-no-manuscript-data", - ["style"] = "editorial-stylised-realism", - ["generationSource"] = "Demand generation", - ["demandKey"] = demandKey, - ["locationType"] = type - } - }; - } - - private static IllustrationGenerationSpecification AssetDemandSpecification(string code, string demandKey, string type, string colour) - => new() - { - Category = IllustrationLibraryCategories.Asset, - Code = code, - Title = $"{ReadableToken(colour)} {ReadableToken(type)}", - Description = "Demand-created reusable asset illustration for Story Intelligence matching.", - VisualSubject = colour == "Unknown" - ? $"A clear reusable {ReadableToken(type).ToLowerInvariant()} object." - : $"A clear reusable {colour.ToLowerInvariant()} {ReadableToken(type).ToLowerInvariant()} object.", - Composition = "single dominant centred object, crisp silhouette, full object visible where practical, no text or logos", - Mood = "clean, readable, evidence-like, story-ready", - ObjectType = type, - Colour = colour, - Era = "Contemporary or late twentieth century", - Palette = colour == "Unknown" - ? ["clear blue", "warm light", "balanced neutral"] - : [colour.ToLowerInvariant(), "clear blue", "warm light"], - MetadataTags = ["asset", "demand-generated", "story-intelligence"], - Metadata = new(StringComparer.OrdinalIgnoreCase) - { - ["libraryPurpose"] = "Story Intelligence demand-generated asset", - ["privacy"] = "generic-no-manuscript-data", - ["style"] = "editorial-stylised-realism", - ["generationSource"] = "Demand generation", - ["demandKey"] = demandKey, - ["objectType"] = type, - ["colour"] = colour - } - }; - - private static string ReadableToken(string value) - => Regex.Replace(value, "([a-z])([A-Z])", "$1 $2"); - - private static string BalancedSkinToneFallback(string demandKey) - { - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(demandKey)); - return (hash[0] % 3) switch - { - 0 => "Light", - 1 => "Medium", - _ => "Dark" - }; - } - - private static string SkinToneInstruction(string skinTone) - => skinTone switch - { - StoryIntelligenceIllustrationCompatibility.SkinTones.Light => "must have light/fair skin; do not depict dark or black skin unless explicit manuscript evidence says so", - StoryIntelligenceIllustrationCompatibility.SkinTones.Medium => "must have medium skin tone; do not drift to dark or very pale skin", - StoryIntelligenceIllustrationCompatibility.SkinTones.Dark => "must have dark skin tone", - _ => "not specified" - }; - - private static string PresentationPrompt(string presentation) - => presentation switch - { - StoryIntelligenceIllustrationCompatibility.Presentations.Feminine => "a recognisably feminine woman or girl, not androgynous and not masculine", - StoryIntelligenceIllustrationCompatibility.Presentations.Masculine => "a recognisably masculine man or boy, not androgynous and not feminine", - _ => "the requested presentation" - }; - - private static Dictionary BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype) - { - var observations = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var scene in prototype.Scenes) - { - foreach (var character in scene.Characters) - { - if (string.IsNullOrWhiteSpace(character.Id) || string.IsNullOrWhiteSpace(character.Name)) - { - continue; - } - - if (!observations.TryGetValue(character.Id, out var observation)) - { - observation = new CharacterObservation(character.Id, character.Name); - observations[character.Id] = observation; - } - - observation.Significance = Math.Max(observation.Significance, character.Weight); - observation.TextEvidence.Add(character.Name); - observation.TextEvidence.Add(character.Role); - observation.TextEvidence.Add(character.Relevance); - observation.IdentityEvidence.Add(character.Name); - observation.IdentityEvidence.Add(character.Role); - observation.IdentityEvidence.Add(character.Relevance); - foreach (var relationship in scene.Relationships.Where(item => string.Equals(item.SourceId, character.Id, StringComparison.OrdinalIgnoreCase) - || string.Equals(item.TargetId, character.Id, StringComparison.OrdinalIgnoreCase))) - { - observation.TextEvidence.Add(relationship.Label); - observation.TextEvidence.Add(relationship.State); - observation.RelationshipEvidence.Add(relationship.Label); - observation.RelationshipEvidence.Add(relationship.State); - } - } - } - - foreach (var observation in observations.Values) - { - observation.Evidence = CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence)); - } - - return observations; - } - - private static CharacterEvidence InferEvidence(string characterName, IEnumerable identityValues, IEnumerable relationshipValues) - { - return CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(characterName, identityValues, relationshipValues)); - } - - private static bool ContainsAny(string text, params string[] needles) - => needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase)); - - private static EvidenceSignal RelationshipPresentation(string text) - { - if (ContainsAny(text, "mother", " mum", "aunt", "sister", "daughter", "wife", "grandmother", " niece")) - { - return new("Feminine", 0.92m); - } - - if (ContainsAny(text, "father", " dad", "uncle", "brother", "son", "husband", "grandfather", " nephew")) - { - return new("Masculine", 0.92m); - } - - return EvidenceSignal.Unknown; - } - - private static EvidenceSignal PronounPresentation(string text) - { - if (ContainsAny(text, " she ", " her ", " hers ", " herself ")) - { - return new("Feminine", 0.86m); - } - - if (ContainsAny(text, " he ", " him ", " his ", " himself ")) - { - return new("Masculine", 0.86m); - } - - return EvidenceSignal.Unknown; - } - - private static EvidenceSignal NamePresentation(string text) - { - if (ContainsAny(text, " alison ", " annie ", " anna ", " anne ", " beth ", " chrissy ", " grace ", " helen ", " elen ", " judy ", " katherine ", " maggie ", " mary ", " rebecca ", " rosie ", " sharon ", " sophie ")) - { - return new("Feminine", 0.82m); - } - - if (ContainsAny(text, " colin ", " david ", " george ", " graham ", " greg ", " john ", " kevin ", " phil ", " roger ", " rick ", " rob ", " simon ")) - { - return new("Masculine", 0.82m); - } - - return EvidenceSignal.Unknown; - } - - private static string FirstKnown(params string[] values) - => values.FirstOrDefault(value => !IsUnknown(value) && !string.Equals(value, "Androgynous", StringComparison.OrdinalIgnoreCase)) ?? values.LastOrDefault() ?? "Unknown"; - - private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned) - { - var score = 0; - var rejected = new List(StoryIntelligenceIllustrationCompatibility.CharacterHardRejections( - observation.Evidence.Profile, - candidate.Metadata.Profile, - isNamedCharacter: !IsUnresolvedName(observation.Name), - alreadyAssignedToAnotherSignificantCharacter: alreadyAssigned && observation.IsSignificant)); - if (observation.Evidence.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown - && candidate.Metadata.AgeBand is StoryIntelligenceIllustrationCompatibility.AgeBands.Child - or StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen - or StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen) - { - rejected.Add("unknown age should use an adult-compatible illustration until explicit child or teen evidence appears"); - } - - AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band"); - AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, observation.Evidence.HasStrongPresentationEvidence ? 42 : 25, "presentation"); - AddScore(observation.Evidence.HairColour, candidate.Metadata.HairColour, 20, "hair colour"); - AddScore(observation.Evidence.SkinTone, candidate.Metadata.SkinTone, 20, "skin tone"); - AddScore(observation.Evidence.HairLength, candidate.Metadata.HairLength, 8, "hair length"); - AddScore(observation.Evidence.Glasses, candidate.Metadata.Glasses, 7, "glasses"); - AddScore(observation.Evidence.FacialHair, candidate.Metadata.FacialHair, 7, "facial hair"); - - if (candidate.Item.Status == IllustrationLibraryStatuses.Approved) - { - score += 3; - } - - return new(candidate, Math.Clamp(score, 0, 100), alreadyAssigned, rejected); - - void AddScore(string evidence, string value, int weight, string label) - { - if (IsUnknown(evidence)) - { - score += weight / 2; - return; - } - - if (string.Equals(evidence, value, StringComparison.OrdinalIgnoreCase)) - { - score += weight; - return; - } - - if (IsUnknown(value)) - { - score += weight / 3; - rejected.Add($"{label} unknown in illustration metadata"); - return; - } - - rejected.Add($"{label} mismatch: wanted {evidence}, candidate {value}"); - } - } - - private static decimal Confidence(int score) - => Math.Round(Math.Clamp(score, 0, 100) / 100m, 2); - - private static string ReassignmentReason(CharacterObservation observation, MatchScore existing, MatchScore chosen) - => existing.Rejections.Count > 0 - ? $"Reassigned {observation.Name} because {existing.Candidate.Item.StableCode} violated hard constraints: {string.Join("; ", existing.Rejections)}." - : $"Reassigned {observation.Name} because new structured evidence made {existing.Candidate.Item.StableCode} materially weaker than {chosen.Candidate.Item.StableCode}."; - - private void ApplyAssignment( - StoryIntelligenceExperiencePrototypeViewModel prototype, - CharacterObservation observation, - StoryIntelligenceCharacterIllustrationAssignment? existing, - MatchScore chosen, - StoryIntelligenceCharacterIllustrationAssignment saved, - string reason, - string? reasonReassigned, - string evidenceJson, - string candidateDiagnostics) - { - foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase))) - { - var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl) - ? character.ImagePath - : character.ImageResolution.FallbackImageUrl; - character.LibraryCode = chosen.Candidate.Item.StableCode; - character.ImagePath = chosen.Candidate.PublicUrl!; - character.ImageResolution = new StoryIntelligenceExperienceImageResolution - { - EntityId = character.Id, - StableCode = chosen.Candidate.Item.StableCode, - IllustrationLibraryItemId = chosen.Candidate.Item.IllustrationLibraryItemID, - Status = chosen.Candidate.Item.Status, - FinalImageUrl = chosen.Candidate.PublicUrl, - FallbackImageUrl = fallbackImageUrl, - FallbackUsed = false, - AssignmentConfidence = saved.MatchConfidence, - MatchingScore = saved.MatchingScore, - RejectedCandidates = candidateDiagnostics, - ReasonChosen = reason, - ReasonReassigned = reasonReassigned, - PreviousIllustration = existing?.StableCode, - PreviousEvidence = existing?.EvidenceJson, - CurrentEvidence = evidenceJson, - DemandKey = DemandKey(observation.Evidence), - EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings), - DemandStatus = "Satisfied", - ResolvedPresentation = observation.Evidence.Presentation, - ResolvedAgeBand = observation.Evidence.AgeBand, - RelationshipDerivedConfidence = observation.Evidence.RelationshipConfidence, - NameDerivedConfidence = observation.Evidence.NameConfidence, - PronounDerivedConfidence = observation.Evidence.PronounConfidence, - IllustrationReuseCount = 1, - IllustrationAllocationStatus = "Unique allocation" - }; - } - } - - private static void ApplyDemandDiagnostics(StoryIntelligenceExperiencePrototypeViewModel prototype, CharacterObservation observation, IReadOnlyList allScores, string demandStatus) - { - var diagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions); - foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase))) - { - var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl) - ? character.ImagePath - : character.ImageResolution.FallbackImageUrl; - character.LibraryCode = string.Empty; - if (!string.IsNullOrWhiteSpace(fallbackImageUrl)) - { - character.ImagePath = fallbackImageUrl; - } - - character.ImageResolution = new StoryIntelligenceExperienceImageResolution - { - EntityId = character.Id, - StableCode = string.Empty, - IllustrationLibraryItemId = null, - Status = "DemandRecorded", - FinalImageUrl = null, - FallbackImageUrl = fallbackImageUrl, - FallbackUsed = true, - MatchingScore = allScores.FirstOrDefault()?.Score, - RejectedCandidates = diagnostics, - ReasonChosen = "No suitable unused illustration matched structured character evidence.", - CurrentEvidence = EvidenceTraceJson(observation, null), - DemandKey = DemandKey(observation.Evidence), - EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings), - DemandStatus = demandStatus, - ResolvedPresentation = observation.Evidence.Presentation, - ResolvedAgeBand = observation.Evidence.AgeBand, - RelationshipDerivedConfidence = observation.Evidence.RelationshipConfidence, - NameDerivedConfidence = observation.Evidence.NameConfidence, - PronounDerivedConfidence = observation.Evidence.PronounConfidence, - IllustrationAllocationStatus = "Awaiting generated unique illustration" - }; - } - } - - private string? ResolveExistingPublicUploadUrl(string? storedPath) - { - var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library"); - if (physicalPath is null || !File.Exists(physicalPath)) - { - return null; - } - - var normalised = storedPath!.Replace('\\', '/').Trim(); - if (!normalised.StartsWith('/')) - { - normalised = "/" + normalised.TrimStart('/'); - } - - return EncodeUrlPath(normalised); - } - - private static string EncodeUrlPath(string path) - => string.Join( - '/', - path.Split('/', StringSplitOptions.None) - .Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment))); - - private static bool IsUnknown(string? value) - => string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase); - - private static string FirstConfigured(params string?[] values) - => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; - - private static bool IsUnresolvedName(string? value) - { - var clean = (value ?? string.Empty).Trim().ToLowerInvariant(); - return string.IsNullOrWhiteSpace(clean) - || clean is "unknown" or "unknown figure" or "unidentified person" or "unidentified woman" or "unidentified man" or "narrator" - || clean.StartsWith("unknown ", StringComparison.Ordinal) - || clean.StartsWith("unidentified ", StringComparison.Ordinal); - } - - private static void ApplyGroupDiagnostics(StoryIntelligenceExperiencePrototypeViewModel prototype, CharacterObservation observation) - { - foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase))) - { - character.LibraryCode = string.Empty; - character.ImagePath = "/images/story-intelligence/prototype/character-group.svg"; - character.ImageResolution = new StoryIntelligenceExperienceImageResolution - { - EntityId = character.Id, - StableCode = string.Empty, - IllustrationLibraryItemId = null, - Status = "GroupEntity", - FinalImageUrl = null, - FallbackImageUrl = character.ImagePath, - FallbackUsed = true, - ReasonChosen = "Collective group entity; reusable group fallback used without unique portrait assignment or demand.", - CurrentEvidence = EvidenceTraceJson(observation, null), - DemandStatus = "Not required for group entity", - ResolvedPresentation = "Unknown", - ResolvedAgeBand = "Unknown", - IllustrationAllocationStatus = "Group entity" - }; - } - } - - private static string NormaliseUnknown(string? value, string fallback) - => IsUnknown(value) ? fallback : value!; - - private static string StableHash(string value) - => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12].ToLowerInvariant(); - - private sealed class CharacterObservation(string characterKey, string name) - { - public string CharacterKey { get; } = characterKey; - public string Name { get; } = name; - public int Significance { get; set; } - public bool IsSignificant => Significance >= 50; - public List TextEvidence { get; } = []; - public List IdentityEvidence { get; } = []; - public List RelationshipEvidence { get; } = []; - public CharacterEvidence Evidence { get; set; } = CharacterEvidence.Unknown; - } - - private sealed record EvidenceSignal(string Value, decimal Confidence) - { - public static EvidenceSignal Unknown { get; } = new("Unknown", 0); - } - - private sealed record CharacterEvidence( - string AgeBand, - string Presentation, - string HairColour, - string SkinTone, - string HairLength, - string Glasses, - string FacialHair, - decimal RelationshipConfidence, - decimal NameConfidence, - decimal PronounConfidence, - IReadOnlyList Warnings, - CharacterEvidenceProfile Profile) - { - public bool HasStrongPresentationEvidence => RelationshipConfidence >= 0.8m || NameConfidence >= 0.75m || PronounConfidence >= 0.8m; - public static CharacterEvidence Unknown { get; } = From(new CharacterEvidenceProfile("Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 0, 0, 0, 0, 0, [])); - public static CharacterEvidence From(CharacterEvidenceProfile profile) - => new( - profile.AgeBand, - profile.Presentation, - profile.HairColour, - profile.SkinTone, - profile.HairLength, - profile.Glasses, - profile.FacialHair, - profile.RelationshipConfidence, - profile.NameConfidence, - profile.PronounConfidence, - profile.Warnings, - profile); - } - - private sealed record CharacterCandidate(IllustrationLibraryItem Item, CharacterIllustrationMetadata Metadata, string? PublicUrl) - { - public static CharacterCandidate? From(IllustrationLibraryItem item) - { - var metadata = CharacterIllustrationMetadata.From(item); - return metadata is null ? null : new(item, metadata, null); - } - } - - private sealed record CharacterIllustrationMetadata(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair, IllustrationCharacterMetadata Profile) - { - public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item) - { - var profile = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item); - - return new( - profile.AgeBand, - profile.Presentation, - profile.HairColour, - profile.SkinTone, - profile.HairLength, - profile.Glasses, - profile.FacialHair, - profile); - } - } - - private sealed record MatchScore(CharacterCandidate Candidate, int Score, bool IsAlreadyAssignedToAnotherSignificantCharacter, IReadOnlyList Rejections); - - private sealed record MatchDiagnostics(int IllustrationLibraryItemID, string StableCode, int Score, IReadOnlyList Rejections) - { - public static MatchDiagnostics From(MatchScore score) - => new(score.Candidate.Item.IllustrationLibraryItemID, score.Candidate.Item.StableCode, score.Score, score.Rejections); - } -} diff --git a/PlotLine/Services/StoryIntelligenceOperationalServices.cs b/PlotLine/Services/StoryIntelligenceOperationalServices.cs new file mode 100644 index 0000000..26bbe01 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceOperationalServices.cs @@ -0,0 +1,90 @@ +using PlotLine.Data; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceEntitlementService +{ + Task EnsureCanQueueAsync(int userId); + Task EnsureCanExecuteAsync(int userId); +} + +public sealed class StoryIntelligenceEntitlementService(ISubscriptionService subscriptions) : IStoryIntelligenceEntitlementService +{ + public Task EnsureCanQueueAsync(int userId) => EnsureAllowedAsync(userId); + + public Task EnsureCanExecuteAsync(int userId) => EnsureAllowedAsync(userId); + + private async Task EnsureAllowedAsync(int userId) + { + var subscription = await subscriptions.GetCurrentSubscriptionAsync(userId); + if (!IsActive(subscription, DateTime.UtcNow)) + { + throw new SubscriptionLimitException("Story Intelligence requires an active subscription before paid AI work can be queued or processed."); + } + } + + private static bool IsActive(UserSubscriptionDetail? subscription, DateTime utcNow) + { + if (subscription is null || !subscription.IsActive || !subscription.SubscriptionLevelIsActive) + { + return false; + } + + if (subscription.EndDateUTC.HasValue && subscription.EndDateUTC.Value <= utcNow) + { + return false; + } + + if (subscription.CurrentPeriodEndUTC.HasValue + && subscription.CurrentPeriodEndUTC.Value <= utcNow + && !string.IsNullOrWhiteSpace(subscription.StripeSubscriptionId)) + { + return false; + } + + var status = (subscription.StripeStatus ?? string.Empty).Trim().ToLowerInvariant(); + return status is not ("canceled" or "cancelled" or "incomplete_expired" or "ended" or "expired" or "inactive"); + } +} + +public interface IStoryIntelligenceCompletionNotificationService +{ + Task NotifyIfImportTerminalAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken = default); +} + +public sealed class StoryIntelligenceCompletionNotificationService( + IStoryIntelligencePipelineRepository pipelines, + IUserRepository users, + IEmailService emails, + ILogger logger) : IStoryIntelligenceCompletionNotificationService +{ + public async Task NotifyIfImportTerminalAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken = default) + { + if (run.BookID is not int bookId) + { + return; + } + + var target = await pipelines.TryBeginCompletionNotificationAsync(bookId, run.UserID); + if (target is null) + { + return; + } + + var user = await users.GetByIdAsync(target.UserID); + if (user is null || string.IsNullOrWhiteSpace(user.Email)) + { + logger.LogWarning("Story Intelligence import notification skipped because user {UserID} has no email target.", target.UserID); + return; + } + + await emails.SendStoryIntelligenceImportNotificationAsync(user.Email, user.DisplayName, target); + logger.LogInformation( + "Queued Story Intelligence import {NotificationKind} notification for import session {ImportSessionID}, book {BookID}, user {UserID}.", + target.NotificationKind, + target.ImportSessionID, + target.BookID, + target.UserID); + } +} diff --git a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs index 5b999f9..8673124 100644 --- a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs +++ b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs @@ -18,6 +18,7 @@ public sealed class StoryIntelligenceResultPersistenceService( IStoryIntelligenceResultRepository repository, IStoryIntelligenceClient client, ISceneImportResolver importResolver, + IStoryIntelligenceEntitlementService entitlement, ILogger logger) : IStoryIntelligenceResultPersistenceService { private static readonly JsonSerializerOptions JsonOptions = new() @@ -63,6 +64,7 @@ public sealed class StoryIntelligenceResultPersistenceService( public async Task QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form) { + await entitlement.EnsureCanQueueAsync(userId); if (string.IsNullOrWhiteSpace(form.ChapterText)) { throw new InvalidOperationException("Enter chapter text before queueing a persisted Story Intelligence run."); diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs deleted file mode 100644 index 9e14343..0000000 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ /dev/null @@ -1,2552 +0,0 @@ -using System.Diagnostics; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using PlotLine.Data; -using PlotLine.Models; -using PlotLine.ViewModels; - -namespace PlotLine.Services; - -public interface IStoryIntelligenceVisualisationSnapshotService -{ - Task BuildSnapshotAsync(int runId, int userId); - Task BuildImportSessionSnapshotAsync(int importSessionId, int userId); - Task BuildReplaySnapshotAsync(int importSessionId, int userId); - Task> ListRunOptionsAsync(int userId); - Task> ListImportSessionOptionsAsync(int userId); -} - -public sealed class StoryIntelligenceVisualisationSnapshotService( - IStoryIntelligenceResultRepository repository, - IStoryIntelligencePipelineRepository pipelines, - IStoryIntelligenceSourceRepository sources, - IIllustrationLibraryService illustrationLibrary, - IStoryMemoryRepository storyMemoryRepository, - IIllustrationLibraryRepository illustrationRepository, - IUploadStorageService uploadStorage, - ILogger logger) : IStoryIntelligenceVisualisationSnapshotService -{ - private const string FallbackRoot = "/images/story-intelligence/prototype"; - private const int SceneWindowPrevious = 4; - private const int SceneWindowFollowing = 2; - private const int SnapshotWarningBytes = 1024 * 1024; - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true - }; - - public async Task> ListRunOptionsAsync(int userId) - { - var runs = await repository.ListRunsAsync(); - return runs - .Where(run => run.UserID == userId) - .OrderByDescending(run => run.CreatedUtc) - .Take(12) - .Select(run => new StoryIntelligenceExperienceRunOption - { - RunId = run.StoryIntelligenceRunID, - Status = run.Status, - CreatedUtc = run.CreatedUtc, - Label = $"{DisplayTitle(run.ProjectTitle, "Project")} / {DisplayTitle(run.BookTitle, "Book")} - {run.Status} - {run.CreatedUtc:u}" - }) - .ToList(); - } - - public async Task> ListImportSessionOptionsAsync(int userId) - { - await EnsureActiveImportSessionsAsync(userId); - var sessions = await pipelines.ListForUserAsync(userId); - return sessions - .OrderByDescending(session => session.UpdatedUtc) - .Take(12) - .Select(session => new StoryIntelligenceExperienceRunOption - { - ImportSessionId = session.StoryIntelligenceBookPipelineID, - RunId = session.LastRunID ?? 0, - Status = session.Status, - CreatedUtc = session.CreatedUtc, - Label = $"{DisplayTitle(session.ProjectName, "Project")} / {DisplayTitle(session.BookDisplayTitle, "Book")} - {session.Status} - started {session.CreatedUtc:u}" - }) - .ToList(); - } - - private async Task EnsureActiveImportSessionsAsync(int userId) - { - var runs = await repository.ListRunsAsync(); - var activeBookRuns = runs - .Where(run => run.UserID == userId - && run.ProjectID.HasValue - && run.BookID.HasValue - && !IsTerminal(run.Status)) - .GroupBy(run => new { ProjectID = run.ProjectID!.Value, BookID = run.BookID!.Value }) - .Select(group => group.OrderByDescending(run => run.CreatedUtc).First()) - .ToList(); - - foreach (var run in activeBookRuns) - { - await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest - { - ProjectID = run.ProjectID!.Value, - BookID = run.BookID!.Value, - CurrentStage = StoryIntelligencePipelineStages.Chapters, - LastCompletedStage = null, - CurrentReviewStage = null, - Status = StoryIntelligencePipelineStatuses.InProgress, - CompletedUtc = null, - LastRunID = run.StoryIntelligenceRunID - }); - } - } - - public async Task BuildSnapshotAsync(int runId, int userId) - { - var stopwatch = Stopwatch.StartNew(); - var run = await repository.GetRunAsync(runId); - if (run is null || run.UserID != userId) - { - return null; - } - - var results = (await repository.ListSceneResultsAsync(runId)).ToList(); - var parsed = results - .Select(result => ReadSceneResult(runId, result)) - .OrderBy(item => SceneSortChapter(item)) - .ThenBy(item => SceneSortNumber(item)) - .ThenBy(item => item.Result.SceneResultID) - .ToList(); - var completed = parsed - .Where(item => item.Scene is not null) - .ToList(); - - var current = PickCurrentScene(run, completed); - var storyMemory = new StoryMemory(); - var visibleSceneIds = VisibleSceneResultIds(completed, current, SceneWindowPrevious, SceneWindowFollowing); - var scenes = BuildSceneStates(run, parsed, completed, current, storyMemory: storyMemory, includedSceneResultIds: visibleSceneIds).ToList(); - if (scenes.Count == 0) - { - scenes.Add(BuildEmptyScene(run)); - } - - var model = new StoryIntelligenceExperiencePrototypeViewModel - { - Mode = "real", - RunId = run.StoryIntelligenceRunID, - BookId = run.BookID, - RunStatus = run.Status, - GeneratedUtc = DateTime.UtcNow, - ChangeToken = ChangeToken(run, results), - IsTerminal = IsTerminal(run.Status), - SnapshotMessage = SnapshotMessage(run, completed.Count), - Diagnostics = BuildDiagnostics(run, results, parsed, completed, current, storyMemory), - ProjectName = DisplayTitle(run.ProjectTitle, "Untitled project"), - BookTitle = DisplayTitle(run.BookTitle, "Untitled book"), - Scenes = scenes - }; - - StabiliseCharacterIllustrations(model); - await illustrationLibrary.ApplyApprovedIllustrationsAsync(model); - ApplyIllustrationDiagnostics(model); - StripLiveDiagnostics(model); - LogPayload(model, $"run {runId}", stopwatch.ElapsedMilliseconds); - return model; - } - - public async Task BuildImportSessionSnapshotAsync(int importSessionId, int userId) - => await BuildImportSessionSnapshotAsync(importSessionId, userId, replayMode: false); - - public async Task BuildReplaySnapshotAsync(int importSessionId, int userId) - => await BuildImportSessionSnapshotAsync(importSessionId, userId, replayMode: true); - - private async Task BuildImportSessionSnapshotAsync(int importSessionId, int userId, bool replayMode) - { - var stopwatch = Stopwatch.StartNew(); - var session = await pipelines.GetByIdForUserAsync(importSessionId, userId); - if (session is null) - { - return null; - } - - var runs = (await repository.ListRunsByBookForUserAsync(session.BookID, userId)) - .OrderBy(run => run.ChapterNumber ?? decimal.MaxValue) - .ThenBy(run => run.ChapterID ?? int.MaxValue) - .ThenBy(run => run.StoryIntelligenceRunID) - .ToList(); - if (runs.Count == 0) - { - return null; - } - - var runSnapshots = new List(); - foreach (var run in runs) - { - var results = (await repository.ListSceneResultsAsync(run.StoryIntelligenceRunID)).ToList(); - var parsed = results - .Select(result => ReadSceneResult(run.StoryIntelligenceRunID, result)) - .OrderBy(item => SceneSortChapter(item)) - .ThenBy(item => SceneSortNumber(item)) - .ThenBy(item => item.Result.SceneResultID) - .ToList(); - var completed = parsed.Where(item => item.Scene is not null).ToList(); - runSnapshots.Add(new(run, results, parsed, completed, PickCurrentScene(run, completed))); - } - - var activeSnapshot = PickActiveRunSnapshot(runSnapshots) ?? runSnapshots.Last(); - var allCompleted = runSnapshots - .SelectMany(snapshot => snapshot.Completed) - .OrderBy(item => SceneSortChapter(item)) - .ThenBy(item => SceneSortNumber(item)) - .ThenBy(item => item.Result.SceneResultID) - .ToList(); - var current = replayMode - ? allCompleted.FirstOrDefault() - : PickAuthoritativeCurrentScene(runSnapshots); - var currentSnapshot = OwnerOf(runSnapshots, current) ?? activeSnapshot; - var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count); - var parsedScenes = runSnapshots.Sum(snapshot => snapshot.Completed.Count); - var totalScenes = Math.Max( - runSnapshots.Sum(snapshot => Math.Max(snapshot.Run.TotalDetectedScenes ?? 0, snapshot.Results.Count)), - parsedScenes); - var sourceChapterCount = (await sources.ListChaptersAsync(session.BookID)).Count; - var bookChapterTotal = Math.Max(1, Math.Max(sourceChapterCount, runSnapshots.Count)); - var completedChapterRuns = runSnapshots.Count(snapshot => IsSuccessfulTerminal(snapshot.Run.Status)); - var sessionSuccessfullyComplete = IsSuccessfulSession(session); - var allKnownRunsSuccessfullyComplete = runSnapshots.Count > 0 && runSnapshots.All(snapshot => IsSuccessfulTerminal(snapshot.Run.Status)); - var isSuccessfullyComplete = sessionSuccessfullyComplete || allKnownRunsSuccessfullyComplete; - var isTerminal = isSuccessfullyComplete || IsTerminalSession(session) || runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status)); - var calculatedVisualisationProgress = OverallProgress(parsedScenes, totalScenes, isSuccessfullyComplete); - var authoritativeImportProgress = BookProgress(runSnapshots, bookChapterTotal, isSuccessfullyComplete); - var displayedProgressReason = sessionSuccessfullyComplete - ? "Successful terminal import session" - : allKnownRunsSuccessfullyComplete - ? "All known chapter runs completed successfully" - : "Weighted chapter-run progress"; - var overallProgressPercent = authoritativeImportProgress; - var overallElapsedMs = SessionElapsedMilliseconds(runSnapshots, isTerminal); - var overallElapsed = FormatDuration(overallElapsedMs); - var overallRemaining = isSuccessfullyComplete ? "Ready to review" : isTerminal ? "Ready to review" : EstimateSessionRemaining(overallElapsedMs, parsedScenes, totalScenes); - var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run); - var scenes = new List(); - var storyMemory = new StoryMemory(); - var visibleSceneIds = replayMode - ? allCompleted.Select(item => item.Result.SceneResultID).ToHashSet() - : VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, followingCount: 0); - foreach (var snapshot in runSnapshots) - { - var runScenes = BuildSceneStates( - snapshot.Run, - snapshot.Parsed, - snapshot.Completed, - current, - overallCompletedScenes: parsedScenes, - overallTotalScenes: totalScenes, - overallChaptersAnalysed: completedChapterRuns, - overallChapterTotal: bookChapterTotal, - overallProgressPercent: overallProgressPercent, - overallStage: overallStage, - overallElapsed: overallElapsed, - overallRemaining: overallRemaining, - overallTerminal: isTerminal, - storyMemory: storyMemory, - includedSceneResultIds: visibleSceneIds) - .ToList(); - scenes.AddRange(runScenes); - } - - if (replayMode) - { - scenes = scenes - .OrderBy(scene => scene.ChapterNumber ?? decimal.MaxValue) - .ThenBy(scene => scene.SceneNumber) - .ThenBy(scene => scene.SceneResultId ?? int.MaxValue) - .Select((scene, index) => ReplayScene(scene, index, scenes.Count)) - .ToList(); - } - - if (scenes.Count == 0) - { - scenes.Add(BuildEmptyScene(activeSnapshot.Run)); - } - - var appearancePreference = await storyMemoryRepository.GetAppearancePreferenceAsync(importSessionId); - var model = new StoryIntelligenceExperiencePrototypeViewModel - { - Mode = replayMode ? "replay" : "real", - ImportSessionId = session.StoryIntelligenceBookPipelineID, - RunId = currentSnapshot.Run.StoryIntelligenceRunID, - BookId = session.BookID, - RunStatus = SessionStatus(session, runSnapshots), - GeneratedUtc = DateTime.UtcNow, - ChangeToken = SessionChangeToken(session, runSnapshots), - IsTerminal = replayMode || isTerminal, - SnapshotMessage = replayMode - ? ReplaySnapshotMessage(session, runSnapshots, parsedScenes) - : SessionSnapshotMessage(session, runSnapshots), - Diagnostics = BuildSessionDiagnostics( - session, - runSnapshots, - currentSnapshot, - current, - parsedScenes, - totalPersistedResults, - totalScenes, - overallProgressPercent, - authoritativeImportProgress, - calculatedVisualisationProgress, - bookChapterTotal, - completedChapterRuns, - displayedProgressReason, - storyMemory, - replayMode, - appearancePreference), - ProjectName = DisplayTitle(session.ProjectName, "Untitled project"), - BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"), - Scenes = scenes - }; - - StabiliseCharacterIllustrations(model); - await ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model); - ApplyIllustrationDiagnostics(model); - StripLiveDiagnostics(model); - LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken); - LogPayload(model, $"import session {importSessionId}", stopwatch.ElapsedMilliseconds); - return model; - } - - private static IEnumerable BuildSceneStates( - StoryIntelligenceSavedRun run, - IReadOnlyList allResults, - IReadOnlyList completed, - ParsedSceneResult? current, - int continuousSceneNumberStart = 1, - int? overallCompletedScenes = null, - int? overallTotalScenes = null, - int? overallChaptersAnalysed = null, - int? overallChapterTotal = null, - int? overallProgressPercent = null, - string? overallStage = null, - string? overallElapsed = null, - string? overallRemaining = null, - bool continuousSceneNumbering = false, - bool overallTerminal = false, - StoryMemory? storyMemory = null, - IReadOnlySet? includedSceneResultIds = null) - { - storyMemory ??= new StoryMemory(); - var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count); - var completedScenes = run.CompletedScenes ?? completed.Count; - var displayIndex = 0; - foreach (var item in completed) - { - var scene = item.Scene!; - storyMemory.Update(scene, item); - var sourceSceneNumber = SceneNumber(item); - var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber; - if (includedSceneResultIds is not null && !includedSceneResultIds.Contains(item.Result.SceneResultID)) - { - continue; - } - - var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID; - var resolvedPovName = storyMemory.ResolvePointOfView(scene); - yield return new StoryIntelligenceExperienceSceneState - { - Id = $"scene-result-{item.Result.SceneResultID}", - IsCurrent = isCurrent, - ChapterRunId = run.StoryIntelligenceRunID, - ChapterNumber = scene.SceneReference?.ChapterNumber ?? run.ChapterNumber, - SceneResultId = item.Result.SceneResultID, - ChapterLabel = ChapterLabel(run, item), - SceneNumber = sceneNumber, - Title = SceneTitle(scene, item.Result, sceneNumber), - Summary = Trim(FirstConfigured(scene.Summary?.Short, scene.Summary?.Detailed, run.CurrentMessage, "Scene analysed."), 180), - Stage = overallStage ?? StageDisplay(run), - TimelineLabel = TimelineLabel(scene, item.Result), - TimelineEvent = Trim(FirstConfigured(scene.Summary?.Short, scene.ScenePurpose?.ObservedFunction, "Scene analysed"), 42), - ProgressPercent = overallProgressPercent - ?? (overallTotalScenes.HasValue - ? OverallProgress(overallCompletedScenes ?? completedScenes, overallTotalScenes.Value, overallTerminal) - : Progress(run, completedScenes, totalScenes)), - ChaptersAnalysed = overallChaptersAnalysed ?? ChapterCount(completed), - ChapterTotal = Math.Max(1, overallChapterTotal ?? run.SourceChapterCount ?? ChapterCount(completed)), - ScenesAnalysed = overallCompletedScenes ?? completedScenes, - SceneTotal = Math.Max(1, overallTotalScenes ?? totalScenes), - ElapsedTime = overallElapsed ?? EstimateElapsed(run), - EstimatedRemaining = overallRemaining ?? EstimateRemaining(run, completedScenes, totalScenes), - PovCharacterId = CharacterId(resolvedPovName), - Location = BuildLocation(scene), - Characters = BuildCharacters(scene, storyMemory), - Assets = BuildAssets(scene), - Relationships = BuildRelationships(scene, storyMemory), - KnowledgeThreads = BuildKnowledge(scene), - Observations = BuildObservations(scene, resolvedPovName), - LatestDiscoveries = isCurrent ? BuildDiscoveries(scene).ToList() : [], - ActivityFeed = isCurrent ? BuildActivity(run, sceneNumber).ToList() : [] - }; - } - } - - private static StoryIntelligenceExperienceSceneState ReplayScene(StoryIntelligenceExperienceSceneState scene, int index, int total) - { - var replayProgress = total <= 1 ? 100 : Math.Clamp((int)Math.Round((index + 1) * 100m / total), 1, 100); - return new() - { - Id = scene.Id, - IsCurrent = index == 0, - ChapterRunId = scene.ChapterRunId, - ChapterNumber = scene.ChapterNumber, - SceneResultId = scene.SceneResultId, - ReplayIndex = index, - GlobalSceneIndex = index + 1, - ChapterLabel = scene.ChapterLabel, - SceneNumber = scene.SceneNumber, - Title = scene.Title, - Summary = scene.Summary, - Stage = $"Replay saved analysis - {scene.ChapterLabel} Scene {scene.SceneNumber:N0}", - TimelineLabel = scene.TimelineLabel, - TimelineEvent = scene.TimelineEvent, - ProgressPercent = replayProgress, - ChaptersAnalysed = scene.ChaptersAnalysed, - ChapterTotal = scene.ChapterTotal, - ScenesAnalysed = index + 1, - SceneTotal = total, - ElapsedTime = $"Scene {index + 1:N0} of {total:N0}", - EstimatedRemaining = index >= total - 1 ? "Replay complete" : "Replay mode", - PovCharacterId = scene.PovCharacterId, - Location = scene.Location, - Characters = scene.Characters, - Assets = scene.Assets, - Relationships = scene.Relationships, - KnowledgeThreads = scene.KnowledgeThreads, - Observations = scene.Observations, - LatestDiscoveries = scene.LatestDiscoveries, - ActivityFeed = scene.ActivityFeed - }; - } - - private static StoryIntelligenceExperienceSceneState BuildEmptyScene(StoryIntelligenceSavedRun run) - => new() - { - Id = $"run-{run.StoryIntelligenceRunID}-waiting", - ChapterLabel = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter", - SceneNumber = 1, - Title = run.Status is StoryIntelligenceRunStatuses.Failed ? "Analysis failed" : "Waiting for analysed scenes", - Summary = FirstConfigured(run.ErrorMessage, run.CurrentMessage, "No scene analysis results have been persisted yet."), - Stage = StageDisplay(run), - TimelineLabel = "Pending", - TimelineEvent = "No scene snapshot yet", - ProgressPercent = Progress(run, run.CompletedScenes ?? 0, run.TotalDetectedScenes ?? 0), - ChaptersAnalysed = 0, - ChapterTotal = Math.Max(1, run.SourceChapterCount ?? 1), - ScenesAnalysed = run.CompletedScenes ?? 0, - SceneTotal = Math.Max(1, run.TotalDetectedScenes ?? 1), - ElapsedTime = EstimateElapsed(run), - EstimatedRemaining = EstimateRemaining(run, run.CompletedScenes ?? 0, run.TotalDetectedScenes ?? 0), - Location = PendingLocation(), - LatestDiscoveries = run.ErrorMessage is null ? [] : [$"Run failed: {Trim(run.ErrorMessage, 90)}"], - ActivityFeed = BuildActivity(run, null).ToList() - }; - - private static ParsedSceneResult? PickCurrentScene(StoryIntelligenceSavedRun run, IReadOnlyList completed) - { - if (completed.Count == 0) - { - return null; - } - - return completed - .OrderBy(item => item.Result.CreatedUtc) - .ThenBy(item => item.Result.SceneResultID) - .Last(); - } - - private static ParsedSceneResult? PickAuthoritativeCurrentScene(IReadOnlyList snapshots) - => snapshots - .SelectMany(snapshot => snapshot.Completed) - .OrderBy(item => item.Result.CreatedUtc) - .ThenBy(item => item.Result.SceneResultID) - .LastOrDefault(); - - private static RunSnapshot? OwnerOf(IReadOnlyList snapshots, ParsedSceneResult? scene) - => scene is null - ? null - : snapshots.FirstOrDefault(snapshot => snapshot.Run.StoryIntelligenceRunID == scene.Result.StoryIntelligenceRunID); - - private static IReadOnlyList BuildCharacters(SceneIntelligenceScene scene, StoryMemory? storyMemory = null) - { - var narratorResolution = storyMemory?.ResolvePointOfView(scene) ?? ResolveNarratorName(scene); - var povName = Clean(narratorResolution ?? scene.PointOfView?.CharacterName); - var states = new List(); - var reservedCodes = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var group in (scene.Characters ?? []) - .Where(character => !string.IsNullOrWhiteSpace(character.Name)) - .GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase) - .Take(5)) - { - if (!string.IsNullOrWhiteSpace(narratorResolution) && IsGenericNarratorName(group.Key)) - { - continue; - } - - var character = group.OrderByDescending(item => item.Confidence ?? 0).First(); - var isPov = !string.IsNullOrWhiteSpace(povName) && string.Equals(group.Key, povName, StringComparison.OrdinalIgnoreCase); - var libraryCode = CharacterLibraryCode(group.Key, character, scene, reservedCodes); - reservedCodes.Add(libraryCode); - states.Add(new StoryIntelligenceExperienceCharacterState - { - Id = CharacterId(group.Key), - LibraryCode = libraryCode, - Name = group.Key, - Role = isPov ? "POV character" : NonPovRole(character), - Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60), - Weight = isPov ? 100 : CharacterWeight(character), - ImagePath = CharacterFallback(group.Key) - }); - } - - if (!string.IsNullOrWhiteSpace(povName) && states.All(character => !string.Equals(character.Name, povName, StringComparison.OrdinalIgnoreCase))) - { - var libraryCode = CharacterLibraryCode(povName, null, scene, reservedCodes); - states.Insert(0, new StoryIntelligenceExperienceCharacterState - { - Id = CharacterId(povName), - LibraryCode = libraryCode, - Name = povName, - Role = "POV character", - Relevance = "Point of view", - Weight = 100, - ImagePath = CharacterFallback(povName) - }); - } - - return states - .OrderByDescending(character => character.Weight) - .GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .ToList(); - } - - private static string NonPovRole(SceneIntelligenceCharacter character) - { - var role = FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"); - return NonPovRole(role, character.MentionedOnly == true); - } - - private static string NonPovRole(string? role, bool referenced = false) - { - role = FirstConfigured(role, referenced ? "Referenced" : "Scene character"); - return role.Contains("pov", StringComparison.OrdinalIgnoreCase) - || role.Contains("point of view", StringComparison.OrdinalIgnoreCase) - || role.Contains("narrator", StringComparison.OrdinalIgnoreCase) - ? referenced ? "Referenced" : "Scene character" - : role; - } - - private static string? ResolveNarratorName(SceneIntelligenceScene scene) - { - var povName = Clean(scene.PointOfView?.CharacterName); - if (!IsGenericNarratorName(povName)) - { - return null; - } - - var evidence = string.Join(' ', new[] - { - scene.PointOfView?.Evidence, - scene.Summary?.Short, - scene.Summary?.Detailed, - scene.ScenePurpose?.ObservedFunction - }.Where(value => !string.IsNullOrWhiteSpace(value))); - - var namedCharacters = (scene.Characters ?? []) - .Where(character => !string.IsNullOrWhiteSpace(character.Name)) - .Select(character => Clean(character.Name)) - .Where(name => !IsGenericNarratorName(name)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - foreach (var name in namedCharacters) - { - if (ContainsIdentityEvidence(evidence, name)) - { - return name; - } - } - - return namedCharacters.Count == 1 ? namedCharacters[0] : null; - } - - private static bool ContainsIdentityEvidence(string evidence, string name) - { - if (string.IsNullOrWhiteSpace(evidence) || string.IsNullOrWhiteSpace(name)) - { - return false; - } - - var lowerEvidence = evidence.ToLowerInvariant(); - var lowerName = name.ToLowerInvariant(); - return lowerEvidence.Contains($"narrated by {lowerName}", StringComparison.Ordinal) - || lowerEvidence.Contains($"narrator is {lowerName}", StringComparison.Ordinal) - || lowerEvidence.Contains($"i am {lowerName}", StringComparison.Ordinal) - || lowerEvidence.Contains($"i'm {lowerName}", StringComparison.Ordinal) - || lowerEvidence.Contains($"my name is {lowerName}", StringComparison.Ordinal); - } - - private static bool IsGenericNarratorName(string? name) - { - var value = Clean(name).ToLowerInvariant(); - return value is "narrator" or "i" or "me" or "my" or "myself" or "first person narrator" or "unnamed narrator"; - } - - private static StoryIntelligenceExperienceLocationState BuildLocation(SceneIntelligenceScene scene) - { - var location = (scene.Locations ?? []) - .Where(item => !string.IsNullOrWhiteSpace(item.Name) && item.MentionedOnly != true) - .OrderByDescending(LocationSelectionScore) - .ThenByDescending(item => Specificity(item.Name)) - .ThenByDescending(item => item.Confidence ?? 0) - .FirstOrDefault(); - - var name = Clean(FirstConfigured(location?.Name, scene.Setting?.LocationName)); - if (string.IsNullOrWhiteSpace(name)) - { - return PendingLocation(); - } - - return new StoryIntelligenceExperienceLocationState - { - Id = $"location-result-{StableKey(name)}", - LibraryCode = LocationLibraryCode(name, location, scene.Setting), - Name = name, - Label = Trim(FirstConfigured(location?.ParentLocationHint, location?.LocationType, location?.GenericRoomType, scene.Setting?.ParentLocationHint, "Active scene location"), 44), - ImagePath = LocationFallback(name) - }; - } - - private static decimal LocationSelectionScore(SceneIntelligenceLocation location) - { - var type = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.Name} {location.LocationType} {location.GenericRoomType}"); - var score = location.PresentInScene == true ? 1000m : 0m; - if (string.Equals(location.LocationType, "named", StringComparison.OrdinalIgnoreCase)) - { - score += 100m; - } - - if (type is "DrivingTestCentre" or "PoliceStation" or "DerelictHospital" or "Hospital" or "Church" or "Restaurant" or "Pub" - or "Workshop" or "Garage" or "HouseExterior" or "Office" or "School" or "WaitingRoom" or "CarPark" or "Shop" - or "PublicToilet" - or "Kitchen" or "Bathroom" or "Bedroom" or "LivingRoom" or "Hallway" or "Stairwell" or "VehicleInterior" - or "DriverSeat" or "PassengerSeat" or "RearSeat" or "FlatInterior") - { - score += 250m; - } - - if (type is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" or "Motorway" or "DualCarriageway" - or "Bridge" or "Flyover" or "Underpass") - { - score -= 100m; - } - - return score; - } - - private static IReadOnlyList BuildAssets(SceneIntelligenceScene scene) - => (scene.Assets ?? []) - .Where(asset => !string.IsNullOrWhiteSpace(asset.Name) && asset.MentionedOnly != true && !IsLowValueAsset(asset)) - .GroupBy(asset => Clean(asset.Name), StringComparer.OrdinalIgnoreCase) - .Select(group => - { - var asset = group.OrderByDescending(item => item.Confidence ?? 0).First(); - return new StoryIntelligenceExperienceAssetState - { - Id = $"asset-result-{StableKey(group.Key)}", - LibraryCode = AssetLibraryCode(group.Key, asset), - Name = group.Key, - Label = Trim(FirstConfigured(asset.Status, asset.AssetType, asset.Notes, "Story asset"), 32), - Weight = AssetWeight(asset), - ImagePath = AssetFallback(group.Key) - }; - }) - .OrderByDescending(asset => asset.Weight) - .Take(5) - .ToList(); - - private static IReadOnlyList BuildRelationships(SceneIntelligenceScene scene, StoryMemory? storyMemory = null) - { - var current = (scene.Relationships ?? []) - .Where(item => !string.IsNullOrWhiteSpace(item.CharacterA) && !string.IsNullOrWhiteSpace(item.CharacterB)) - .Select(item => new StoryIntelligenceExperienceRelationshipState - { - Id = $"relationship-result-{StableKey(item.CharacterA!)}-{StableKey(item.CharacterB!)}-{StableKey(item.RelationshipSignal)}", - SourceId = CharacterId(item.CharacterA), - TargetId = CharacterId(item.CharacterB), - Label = Trim(FirstConfigured(item.RelationshipSignal, "Relationship signal"), 44), - State = Trim(FirstConfigured(item.Evidence, item.RelationshipSignal, "Emerging"), 58), - Weight = ConfidenceWeight(item.Confidence, 70) - }) - .OrderByDescending(item => item.Weight) - .ToList(); - var seen = new HashSet(current.Select(item => $"{item.SourceId}|{item.TargetId}"), StringComparer.OrdinalIgnoreCase); - var inferredKinship = InferredKinshipRelationships(scene, seen); - var memoryRelationships = (storyMemory?.Relationships ?? []) - .Where(item => seen.Add($"{item.SourceId}|{item.TargetId}")) - .Select(item => new StoryIntelligenceExperienceRelationshipState - { - Id = item.Id, - SourceId = item.SourceId, - TargetId = item.TargetId, - Label = item.Label, - State = item.State, - Weight = item.Id.StartsWith("memory-inferred-relationship-", StringComparison.Ordinal) - ? item.Weight - : Math.Max(44, item.Weight - 16) - }); - - return current.Concat(inferredKinship).Concat(memoryRelationships) - .OrderByDescending(item => item.Weight) - .Take(4) - .ToList(); - } - - private static IReadOnlyList InferredKinshipRelationships(SceneIntelligenceScene scene, HashSet seen) - { - var characters = (scene.Characters ?? []) - .Where(character => !string.IsNullOrWhiteSpace(character.Name)) - .Select(character => new - { - Name = Clean(character.Name), - Text = $"{character.Name} {character.RoleInScene} {character.Notes} {string.Join(' ', character.Actions ?? [])}".ToLowerInvariant() - }) - .DistinctBy(character => character.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - var beth = characters.FirstOrDefault(character => string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase)); - if (beth is null) - { - return []; - } - - var relationships = new List(); - foreach (var character in characters.Where(character => !string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase))) - { - var label = character.Text.Contains("sister", StringComparison.OrdinalIgnoreCase) || string.Equals(character.Name, "Grace", StringComparison.OrdinalIgnoreCase) - ? "Sister" - : character.Text.Contains("aunt", StringComparison.OrdinalIgnoreCase) || character.Name.Contains("Aunt", StringComparison.OrdinalIgnoreCase) - ? "Aunt/Niece" - : character.Text.Contains("mother", StringComparison.OrdinalIgnoreCase) || character.Text.Contains(" mum", StringComparison.OrdinalIgnoreCase) - ? "Mother/Daughter" - : string.Empty; - if (string.IsNullOrWhiteSpace(label)) - { - continue; - } - - var sourceId = CharacterId("Beth"); - var targetId = CharacterId(character.Name); - if (!seen.Add($"{sourceId}|{targetId}")) - { - continue; - } - - relationships.Add(new StoryIntelligenceExperienceRelationshipState - { - Id = $"relationship-inferred-{StableKey("Beth")}-{StableKey(character.Name)}-{StableKey(label)}", - SourceId = sourceId, - TargetId = targetId, - Label = label, - State = $"Inferred from explicit kinship language involving {character.Name}.", - Weight = 88 - }); - } - - return relationships; - } - - private static IReadOnlyList BuildKnowledge(SceneIntelligenceScene scene) - { - var questions = (scene.QuestionsRaised ?? []).Select(question => new StoryIntelligenceExperienceTextItem - { - Id = $"question-result-{StableKey(question.Question)}", - Title = Trim(FirstConfigured(question.Question, "Question raised"), 70), - Detail = Trim(FirstConfigured(question.Evidence, question.Scope, "Open question"), 120), - Tone = "question" - }); - var knowledge = (scene.KnowledgeChanges ?? []).Select(change => new StoryIntelligenceExperienceTextItem - { - Id = $"knowledge-result-{StableKey(change.RecipientCharacter)}-{StableKey(change.KnowledgeItem)}", - Title = Trim(FirstConfigured(change.KnowledgeItem, "Knowledge changed"), 70), - Detail = Trim(FirstConfigured(change.Evidence, change.ChangeType, change.SourceType, "Knowledge update"), 120), - Tone = "secret" - }); - return questions.Concat(knowledge).Take(4).ToList(); - } - - private static IReadOnlyList BuildObservations(SceneIntelligenceScene scene, string? povName) - => (scene.Observations ?? []).Select(observation => new StoryIntelligenceExperienceTextItem - { - Id = $"observation-result-{StableKey(observation.ObservationType)}-{StableKey(observation.SubjectName)}-{StableKey(observation.Predicate)}", - Title = Trim(NamedPovText(FirstConfigured(observation.Description, observation.Predicate, observation.ObservationType, "Story observation"), povName), 74), - Detail = Trim(NamedPovText(FirstConfigured(observation.Evidence, $"{observation.SubjectName} {observation.Predicate} {observation.ObjectName}".Trim(), "Observed in current scene"), povName), 128), - Tone = "insight" - }) - .Take(4) - .ToList(); - - private static string NamedPovText(string text, string? povName) - { - var name = Clean(povName); - if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(name) || IsGenericNarratorName(name)) - { - return text; - } - - return text - .Replace("The narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase) - .Replace("the narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase) - .Replace("The narrator", name, StringComparison.OrdinalIgnoreCase) - .Replace("the narrator", name, StringComparison.OrdinalIgnoreCase) - .Replace("Narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase) - .Replace("narrator's", $"{name}'s", StringComparison.OrdinalIgnoreCase) - .Replace("Narrator", name, StringComparison.OrdinalIgnoreCase) - .Replace("narrator", name, StringComparison.OrdinalIgnoreCase); - } - - private static IEnumerable BuildDiscoveries(SceneIntelligenceScene scene) - { - foreach (var character in (scene.Characters ?? []).Where(item => !string.IsNullOrWhiteSpace(item.Name)).Take(2)) - { - yield return $"Character found: {Clean(character.Name)}"; - } - - var location = FirstConfigured(scene.Locations?.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Name))?.Name, scene.Setting?.LocationName); - if (!string.IsNullOrWhiteSpace(location)) - { - yield return $"Location found: {Clean(location)}"; - } - - foreach (var asset in (scene.Assets ?? []).Where(item => !string.IsNullOrWhiteSpace(item.Name) && !IsLowValueAsset(item)).Take(2)) - { - yield return $"Asset found: {Clean(asset.Name)}"; - } - } - - private static IEnumerable BuildActivity(StoryIntelligenceSavedRun run, int? sceneNumber) - { - if (!string.IsNullOrWhiteSpace(run.CurrentMessage)) - { - yield return run.CurrentMessage!; - } - - if (sceneNumber.HasValue) - { - yield return $"Scene {sceneNumber:N0} snapshot loaded"; - } - - yield return $"Run status: {run.Status}"; - } - - private ParsedSceneResult ReadSceneResult(int runId, StoryIntelligenceSavedSceneResult result) - { - var scene = TryReadScene(result, out var error); - if (scene is null) - { - logger.LogWarning( - "Story Intelligence visualisation skipped unparseable scene result {SceneResultId} for run {RunId}: {ParseError}", - result.SceneResultID, - runId, - error ?? "Parsed scene was empty or missing schema version."); - } - - return new ParsedSceneResult(result, scene, error); - } - - private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError) - { - safeError = null; - if (string.IsNullOrWhiteSpace(result.ParsedJson)) - { - safeError = "Parsed JSON was empty."; - return null; - } - - try - { - var direct = JsonSerializer.Deserialize(result.ParsedJson, JsonOptions); - if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion)) - { - return direct; - } - - using var document = JsonDocument.Parse(result.ParsedJson); - if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene)) - { - return parsedScene.Deserialize(JsonOptions); - } - } - catch (JsonException ex) - { - safeError = ex.Message; - } - - safeError ??= "Parsed JSON did not contain a scene payload."; - return null; - } - - private static string ChangeToken(StoryIntelligenceSavedRun run, IReadOnlyList results) - { - var resultToken = string.Join(';', results - .OrderBy(result => result.SceneResultID) - .Select(result => string.Join('|', - result.SceneResultID, - result.ChapterID, - result.SceneID, - result.TemporarySceneNumber, - result.ValidationErrorsCount, - result.ValidationWarningsCount, - result.InputTokens, - result.OutputTokens, - result.TotalTokens, - result.DurationMs, - result.CreatedUtc.ToUniversalTime().Ticks, - StableContentHash(result.ParsedJson)))); - var raw = string.Join('|', - run.StoryIntelligenceRunID, - run.Status, - run.CurrentStage, - run.CurrentMessage, - run.CompletedScenes, - run.FailedScenes, - run.TotalDetectedScenes, - run.CompletedUtc?.ToUniversalTime().Ticks, - run.UpdatedUtc.ToUniversalTime().Ticks, - run.TotalInputTokens, - run.TotalOutputTokens, - run.TotalTokens, - run.TotalDurationMs, - run.ErrorMessage, - run.ErrorDetail is null ? string.Empty : StableContentHash(run.ErrorDetail), - results.Count, - resultToken); - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant(); - } - - private static string SessionChangeToken(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots) - { - var raw = string.Join('|', - session.StoryIntelligenceBookPipelineID, - session.Status, - session.CurrentStage, - session.LastCompletedStage, - session.CurrentReviewStage, - session.UpdatedUtc.ToUniversalTime().Ticks, - string.Join("~", snapshots.Select(snapshot => ChangeToken(snapshot.Run, snapshot.Results)))); - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant(); - } - - private static RunSnapshot? PickActiveRunSnapshot(IReadOnlyList snapshots) - => snapshots - .Where(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Running, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(snapshot => snapshot.Run.UpdatedUtc) - .ThenByDescending(snapshot => snapshot.Run.StoryIntelligenceRunID) - .FirstOrDefault() - ?? snapshots - .Where(snapshot => !IsTerminal(snapshot.Run.Status)) - .OrderBy(snapshot => snapshot.Run.ChapterNumber ?? decimal.MaxValue) - .ThenBy(snapshot => snapshot.Run.StoryIntelligenceRunID) - .FirstOrDefault() - ?? snapshots - .Where(snapshot => snapshot.Completed.Count > 0) - .OrderByDescending(snapshot => snapshot.Run.UpdatedUtc) - .ThenByDescending(snapshot => snapshot.Run.StoryIntelligenceRunID) - .FirstOrDefault() - ?? snapshots.LastOrDefault(); - - private static string OverallStage(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedRun activeRun) - { - if (!IsTerminal(activeRun.Status)) - { - return FirstConfigured(activeRun.CurrentStage, activeRun.Status); - } - - return session.CurrentStage switch - { - StoryIntelligencePipelineStages.Complete => "Ready for Review", - StoryIntelligencePipelineStages.Chapters => "Reading Chapters", - _ => session.CurrentStage - }; - } - - private static string EstimateSessionRemaining(long elapsedMs, int completedScenes, int totalScenes) - { - if (totalScenes <= 0 || completedScenes <= 0) - { - return "Calculating"; - } - - if (completedScenes >= totalScenes) - { - return "Ready to review"; - } - - var averageMs = elapsedMs / Math.Max(1, completedScenes); - return FormatDuration(averageMs * Math.Max(0, totalScenes - completedScenes)); - } - - private static string SessionStatus(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots) - { - if (IsSuccessfulSession(session)) - { - return StoryIntelligenceRunStatuses.Completed; - } - - if (snapshots.Any(snapshot => !IsTerminal(snapshot.Run.Status))) - { - return StoryIntelligenceRunStatuses.Running; - } - - return string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) - ? StoryIntelligenceRunStatuses.Completed - : StoryIntelligenceRunStatuses.CompletedWithWarnings; - } - - private static string SessionSnapshotMessage(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots) - { - if (IsSuccessfulSession(session)) - { - return "Book analysis complete and ready for review."; - } - - var active = snapshots.FirstOrDefault(snapshot => !IsTerminal(snapshot.Run.Status)); - if (active is not null) - { - return BackendStageDisplay(active.Run); - } - - return string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) - ? "Book analysis complete and ready for review." - : "All known chapter runs are complete. Awaiting the next book import stage."; - } - - private sealed class StoryMemory - { - private readonly Dictionary characters = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary relationships = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary chapterPov = new(StringComparer.OrdinalIgnoreCase); - private string? narratorResolvedName; - private string? bookNarratorName; - private string? previousPovName; - private string? previousChapterKey; - - public IReadOnlyCollection Characters => characters.Values; - public IReadOnlyCollection Relationships => relationships.Values; - public decimal NarratorConfidence { get; private set; } - public string RawScenePov { get; private set; } = string.Empty; - public string InheritedChapterPov { get; private set; } = string.Empty; - public string ResolvedPovIdentity { get; private set; } = string.Empty; - public string PovResolutionReason { get; private set; } = string.Empty; - public decimal PovConfidence { get; private set; } - public bool PovChangeDetected { get; private set; } - public int ResolvedCharacterCount => characters.Values.Count(character => character.IsResolved); - public int UnresolvedCharacterCount => characters.Values.Count(character => !character.IsResolved); - private string CurrentChapterKey { get; set; } = string.Empty; - - public void Update(SceneIntelligenceScene scene, ParsedSceneResult result) - { - CurrentChapterKey = ChapterMemoryKey(scene, result); - RawScenePov = Clean(scene.PointOfView?.CharacterName); - InheritedChapterPov = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name ?? string.Empty; - PovChangeDetected = false; - var resolvedNarrator = ResolveNarratorName(scene); - if (!string.IsNullOrWhiteSpace(resolvedNarrator)) - { - narratorResolvedName = resolvedNarrator; - bookNarratorName ??= resolvedNarrator; - NarratorConfidence = Math.Max(NarratorConfidence, 0.82m); - SetChapterPov(CurrentChapterKey, resolvedNarrator, 0.86m, "explicit narrator identity in current scene", result); - } - else if (IsGenericNarratorName(scene.PointOfView?.CharacterName)) - { - NarratorConfidence = Math.Max(NarratorConfidence, 0.35m); - } - else if (!string.IsNullOrWhiteSpace(RawScenePov)) - { - var existing = chapterPov.GetValueOrDefault(CurrentChapterKey); - PovChangeDetected = existing is not null && !string.Equals(existing.Name, RawScenePov, StringComparison.OrdinalIgnoreCase); - bookNarratorName ??= RawScenePov; - SetChapterPov(CurrentChapterKey, RawScenePov, 0.92m, PovChangeDetected ? "explicit named POV change in current scene" : "explicit named POV in current scene", result); - } - - var povName = ResolvePointOfView(scene); - foreach (var character in scene.Characters ?? []) - { - var name = Clean(character.Name); - if (string.IsNullOrWhiteSpace(name) || IsGenericNarratorName(name)) - { - var inheritedPov = ResolvePointOfView(scene); - if (!string.IsNullOrWhiteSpace(inheritedPov)) - { - name = inheritedPov; - } - else - { - name = "Narrator"; - } - } - - var memory = GetOrAddCharacter(name); - memory.Weight = Math.Max(memory.Weight, CharacterWeight(character)); - memory.Role = FirstConfigured(memory.Role, character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"); - memory.Relevance = Trim(FirstConfigured(character.Notes, memory.Relevance, string.Join(", ", character.Actions ?? []), "Remembered from earlier scenes"), 60); - var evidence = $"{name} {character.RoleInScene} {character.Notes} {string.Join(" ", character.Actions ?? [])}"; - ApplyInference(memory, evidence); - InferSemanticRelationship(scene, name, evidence, povName); - } - - InferSummaryRelationships(scene, povName); - - if (!string.IsNullOrWhiteSpace(povName)) - { - var pov = GetOrAddCharacter(povName); - pov.PovCount += 1; - pov.Weight = Math.Max(pov.Weight, 100); - pov.Role = "POV character"; - pov.Relevance = pov.PovCount > 1 ? "Recurring point of view" : "Point of view"; - } - - foreach (var relationship in scene.Relationships ?? []) - { - var a = ResolveCharacterName(relationship.CharacterA); - var b = ResolveCharacterName(relationship.CharacterB); - if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) - { - continue; - } - - ApplyInference(GetOrAddCharacter(a), $"{relationship.RelationshipSignal} {relationship.Evidence}"); - ApplyInference(GetOrAddCharacter(b), $"{relationship.RelationshipSignal} {relationship.Evidence}"); - - var key = $"{CharacterId(a)}|{CharacterId(b)}"; - relationships[key] = new StoryIntelligenceExperienceRelationshipState - { - Id = $"memory-relationship-{StableKey(a)}-{StableKey(b)}", - SourceId = CharacterId(a), - TargetId = CharacterId(b), - Label = Trim(FirstConfigured(relationship.RelationshipSignal, "Relationship remembered"), 44), - State = Trim(FirstConfigured(relationship.Evidence, relationship.RelationshipSignal, "Carried forward from earlier scenes"), 58), - Weight = ConfidenceWeight(relationship.Confidence, 64) - }; - } - - previousPovName = ResolvePointOfView(scene); - previousChapterKey = CurrentChapterKey; - } - - public string? ResolvePointOfView(SceneIntelligenceScene scene) - { - var raw = Clean(scene.PointOfView?.CharacterName); - if (IsGenericNarratorName(raw)) - { - var resolved = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name - ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null); - ResolvedPovIdentity = resolved ?? raw; - PovResolutionReason = resolved is null ? "unresolved narrator" : "inherited same-chapter POV"; - PovConfidence = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Confidence ?? (resolved is null ? 0.35m : 0.74m); - return resolved ?? raw; - } - - var fallback = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name - ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null); - ResolvedPovIdentity = string.IsNullOrWhiteSpace(raw) ? fallback ?? string.Empty : raw; - PovResolutionReason = string.IsNullOrWhiteSpace(raw) ? "inherited previous POV" : "explicit named POV"; - PovConfidence = string.IsNullOrWhiteSpace(raw) ? 0.72m : 0.92m; - return string.IsNullOrWhiteSpace(raw) ? fallback : raw; - } - - private void SetChapterPov(string chapterKey, string name, decimal confidence, string reason, ParsedSceneResult result) - { - chapterPov[chapterKey] = new PovResolution(name, confidence, reason, result.Result.SceneResultID); - InheritedChapterPov = name; - ResolvedPovIdentity = name; - PovResolutionReason = reason; - PovConfidence = confidence; - } - - private static string ChapterMemoryKey(SceneIntelligenceScene scene, ParsedSceneResult result) - => (scene.SceneReference?.ChapterNumber ?? SceneSortChapter(result)).ToString("0.##"); - - private string ResolveCharacterName(string? raw) - { - var name = Clean(raw); - if (!IsGenericNarratorName(name)) - { - return name; - } - - return chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name - ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null) - ?? name; - } - - private void InferSemanticRelationship(SceneIntelligenceScene scene, string characterName, string evidence, string? povName) - { - if (string.Equals(characterName, "Grace", StringComparison.OrdinalIgnoreCase)) - { - AddInferredRelationship("Grace", "Beth", "sister", "Inferred from remembered Alpha Flame family context", 96); - } - - var term = RelationshipTerm(characterName, evidence); - if (term is null) - { - return; - } - - var target = RelationshipTarget(scene, characterName, evidence, povName); - if (string.IsNullOrWhiteSpace(target) || string.Equals(target, characterName, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - AddInferredRelationship(characterName, target, term.Value.Label, term.Value.State, term.Value.Weight); - } - - private void InferSummaryRelationships(SceneIntelligenceScene scene, string? povName) - { - var text = $" {scene.Summary?.Short} {scene.Summary?.Detailed} {scene.ScenePurpose?.ObservedFunction} ".ToLowerInvariant(); - var target = SummaryRelationshipTarget(scene, text, povName); - var summaryTarget = string.IsNullOrWhiteSpace(target) && text.Contains("beth", StringComparison.OrdinalIgnoreCase) ? "Beth" : target; - - if (text.Contains("sister grace", StringComparison.OrdinalIgnoreCase) - || text.Contains("sister named grace", StringComparison.OrdinalIgnoreCase) - || text.Contains("sister, grace", StringComparison.OrdinalIgnoreCase) - || text.Contains("sister Grace", StringComparison.OrdinalIgnoreCase) - || (text.Contains("sister", StringComparison.OrdinalIgnoreCase) && text.Contains("grace", StringComparison.OrdinalIgnoreCase))) - { - AddInferredRelationship("Grace", FirstConfigured(summaryTarget, "Beth"), "sister", "Inferred from scene summary", 86); - } - - if (text.Contains("aunt (elen)", StringComparison.OrdinalIgnoreCase) - || text.Contains("aunt elen", StringComparison.OrdinalIgnoreCase) - || text.Contains("aunt, elen", StringComparison.OrdinalIgnoreCase) - || text.Contains("elen", StringComparison.OrdinalIgnoreCase)) - { - var elen = GetOrAddCharacter("Elen"); - elen.Weight = Math.Max(elen.Weight, 82); - elen.Role = "Inferred aunt"; - elen.Relevance = "Named in scene summary"; - elen.Presentation = "Feminine"; - elen.AgeBand = "Middle Aged"; - AddInferredRelationship("Elen", FirstConfigured(summaryTarget, "Beth"), "aunt", "Inferred from scene summary", 86); - } - } - - private string? SummaryRelationshipTarget(SceneIntelligenceScene scene, string text, string? povName) - { - var candidates = (scene.Characters ?? []) - .Select(character => Clean(character.Name)) - .Concat(characters.Values.Select(character => character.Name)) - .Where(name => !string.IsNullOrWhiteSpace(name) && !IsGenericNarratorName(name)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - if (text.Contains("beth", StringComparison.OrdinalIgnoreCase)) - { - var beth = candidates.FirstOrDefault(candidate => string.Equals(candidate, "Beth", StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrWhiteSpace(beth)) - { - return beth; - } - } - - return !string.IsNullOrWhiteSpace(povName) ? povName : candidates.FirstOrDefault(); - } - - private void AddInferredRelationship(string sourceName, string targetName, string label, string state, int weight) - { - var source = ResolveCharacterName(sourceName); - var target = ResolveCharacterName(targetName); - if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target) || string.Equals(source, target, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - GetOrAddCharacter(source); - GetOrAddCharacter(target); - var key = $"{CharacterId(source)}|{CharacterId(target)}"; - relationships.TryAdd(key, new StoryIntelligenceExperienceRelationshipState - { - Id = $"memory-inferred-relationship-{StableKey(source)}-{StableKey(target)}-{StableKey(label)}", - SourceId = CharacterId(source), - TargetId = CharacterId(target), - Label = Trim(label, 44), - State = Trim(state, 58), - Weight = weight - }); - } - - private static (string Label, string State, int Weight)? RelationshipTerm(string characterName, string evidence) - { - var cleanName = characterName.Trim().ToLowerInvariant(); - if (cleanName is "mother" or "mum" or "father" or "dad" or "sister" or "brother" or "aunt" or "uncle") - { - return null; - } - - var nameText = $" {characterName} ".ToLowerInvariant(); - var evidenceText = $" {evidence} ".ToLowerInvariant(); - if (ContainsAny(evidenceText, "best friend")) return ("best friend", "Inferred from relationship language", 62); - if (ContainsAny(nameText, "mother", " mum ")) return ("mother", "Inferred from family language", 86); - if (ContainsAny(nameText, "father", " dad ")) return ("father", "Inferred from family language", 86); - if (ContainsAny(nameText, "sister")) return ("sister", "Inferred from family language", 86); - if (ContainsAny(nameText, "brother")) return ("brother", "Inferred from family language", 86); - if (ContainsAny(nameText, "aunt")) return ("aunt", "Inferred from family language", 86); - if (ContainsAny(nameText, "uncle")) return ("uncle", "Inferred from family language", 86); - if (ContainsAny(nameText, "daughter")) return ("daughter", "Inferred from family language", 82); - if (ContainsAny(nameText, "son")) return ("son", "Inferred from family language", 82); - if (ContainsAny(nameText, "wife")) return ("wife", "Inferred from relationship language", 64); - if (ContainsAny(nameText, "husband")) return ("husband", "Inferred from relationship language", 64); - if (ContainsAny(nameText, "grandmother")) return ("grandmother", "Inferred from family language", 64); - if (ContainsAny(nameText, "grandfather")) return ("grandfather", "Inferred from family language", 64); - if (ContainsAny(nameText, "twins")) return ("twin", "Inferred from family language", 64); - if (ContainsAny(nameText, "teacher")) return ("teacher", "Inferred from role language", 56); - return null; - } - - private string? RelationshipTarget(SceneIntelligenceScene scene, string characterName, string evidence, string? povName) - { - var text = $" {evidence} ".ToLowerInvariant(); - var candidates = (scene.Characters ?? []) - .Select(character => Clean(character.Name)) - .Concat(characters.Values.Select(character => character.Name)) - .Where(name => !string.IsNullOrWhiteSpace(name) - && !IsGenericNarratorName(name) - && !string.Equals(name, characterName, StringComparison.OrdinalIgnoreCase)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - foreach (var candidate in candidates) - { - var lower = candidate.ToLowerInvariant(); - if (text.Contains($"{lower}'s", StringComparison.OrdinalIgnoreCase) - || text.Contains($"{lower}' ", StringComparison.OrdinalIgnoreCase) - || text.Contains($"of {lower}", StringComparison.OrdinalIgnoreCase) - || text.Contains($"to {lower}", StringComparison.OrdinalIgnoreCase)) - { - return candidate; - } - } - - if (!string.IsNullOrWhiteSpace(povName) - && !string.Equals(povName, characterName, StringComparison.OrdinalIgnoreCase) - && candidates.Any(candidate => string.Equals(candidate, povName, StringComparison.OrdinalIgnoreCase))) - { - return povName; - } - - return candidates.Count == 1 ? candidates[0] : null; - } - - private MemoryCharacter GetOrAddCharacter(string name) - { - var key = CharacterId(name); - if (!characters.TryGetValue(key, out var character)) - { - character = new MemoryCharacter(name); - characters[key] = character; - } - - return character; - } - - private static void ApplyInference(MemoryCharacter character, string evidence) - { - var text = evidence.ToLowerInvariant(); - if (ContainsAny(text, "mother", "mum", "aunt", "daughter", "sister", "wife", "grandmother", "she ", " her ")) - { - character.Presentation = "Feminine"; - } - else if (ContainsAny(text, "father", "dad", "uncle", "son", "brother", "husband", "grandfather", " he ", " him ")) - { - character.Presentation = "Masculine"; - } - else - { - character.Presentation ??= NamePresentation(character.Name); - } - - if (ContainsAny(text, "grandfather", "grandmother", "elderly")) - { - character.AgeBand = "Elderly"; - } - else if (ContainsAny(text, "mother", "father", "parent", "aunt", "uncle", "middle aged", "middle-aged")) - { - character.AgeBand = "Middle Aged"; - } - else if (ContainsAny(text, "teenage", "teenager", "fifteen", "sixteen", "seventeen")) - { - character.AgeBand = "Older Teen"; - } - } - - private static string? NamePresentation(string name) - { - var key = name.ToLowerInvariant(); - if (key is "alison" or "annie" or "anna" or "anne" or "beth" or "chrissy" or "grace" or "helen" or "elen" or "judy" or "katherine" or "maggie" or "mary" or "rebecca" or "rosie" or "sharon" or "sophie") - { - return "Feminine"; - } - - if (key is "colin" or "david" or "george" or "graham" or "greg" or "john" or "kevin" or "phil" or "roger" or "rick" or "rob" or "simon") - { - return "Masculine"; - } - - return null; - } - - private static bool ContainsAny(string text, params string[] needles) - => needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase)); - } - - private sealed class MemoryCharacter(string name) - { - public string Name { get; } = name; - public string Role { get; set; } = "Remembered character"; - public string Relevance { get; set; } = "Known from earlier scenes"; - public int Weight { get; set; } = 48; - public int PovCount { get; set; } - public string? Presentation { get; set; } - public string? AgeBand { get; set; } - public bool IsResolved => !IsGenericNarratorName(Name); - } - - private sealed record PovResolution(string Name, decimal Confidence, string Reason, int LastConfirmedSceneResultId); - - private static string CurrentSceneText(RunSnapshot snapshot) - { - if (snapshot.Run.TotalDetectedScenes.HasValue) - { - var scene = Math.Clamp(snapshot.Run.CompletedScenes ?? snapshot.Completed.Count, 0, snapshot.Run.TotalDetectedScenes.Value); - return $"Scene {scene:N0} of {snapshot.Run.TotalDetectedScenes.Value:N0}"; - } - - return snapshot.Current is null ? "Waiting" : $"Scene {SceneSortNumber(snapshot.Current):0.##}"; - } - - private static string BackendStageDisplay(StoryIntelligenceSavedRun run) - { - var stage = FirstConfigured(run.CurrentStage, run.Status); - var sceneText = run.TotalDetectedScenes.HasValue - ? $"Scene {Math.Clamp((run.CompletedScenes ?? 0) + (IsTerminal(run.Status) ? 0 : 1), 1, Math.Max(1, run.TotalDetectedScenes.Value)):N0}" - : "current scene"; - var chapterText = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##} " : string.Empty; - return stage switch - { - StoryIntelligenceFailureStages.SceneIntelligence => $"Analysing {chapterText}{sceneText}", - StoryIntelligencePipelineStages.CharacterImport or StoryIntelligencePipelineStages.CharacterReview => "Analysing Characters", - StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.RelationshipReview => "Building Relationships", - StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.AssetReview => "Analysing Assets", - StoryIntelligencePipelineStages.LocationImport or StoryIntelligencePipelineStages.LocationReview => "Analysing Locations", - StoryIntelligenceFailureStages.DomainImport or StoryIntelligencePipelineStages.KnowledgeImport or StoryIntelligencePipelineStages.KnowledgeReview => "Inferring Knowledge", - StoryIntelligenceFailureStages.Persistence => "Building Continuity", - _ when stage.Contains("Scene", StringComparison.OrdinalIgnoreCase) => $"Analysing {chapterText}{sceneText}", - _ => FirstConfigured(run.CurrentMessage, stage, $"Analysing {chapterText}{sceneText}") - }; - } - - private static StoryIntelligenceExperienceSnapshotDiagnostics BuildSessionDiagnostics( - StoryIntelligenceBookPipelineState session, - IReadOnlyList snapshots, - RunSnapshot activeSnapshot, - ParsedSceneResult? current, - int parsedScenes, - int totalPersistedResults, - int totalScenes, - int overallProgressPercent, - int authoritativeImportProgress, - int calculatedVisualisationProgress, - int totalChapters, - int completedChapters, - string displayedProgressReason, - StoryMemory storyMemory, - bool replayMode = false, - string appearancePreference = "") - { - var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null)); - var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed."; - var failedChapterSnapshots = snapshots - .Where(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)) - .OrderBy(snapshot => snapshot.Run.ChapterNumber ?? decimal.MaxValue) - .ThenBy(snapshot => snapshot.Run.StoryIntelligenceRunID) - .ToList(); - var failedChapterSummary = string.Join("; ", failedChapterSnapshots.Select(snapshot => - $"Chapter {snapshot.Run.ChapterNumber:0.##} - {FirstConfigured(snapshot.Run.ErrorMessage, snapshot.Run.FailureStage, "analysis failed")}")); - return new() - { - RequestedMode = replayMode ? "replay" : "real", - CurrentImportId = session.StoryIntelligenceBookPipelineID, - RequestedImportSessionId = session.StoryIntelligenceBookPipelineID, - ResolvedImportSessionId = session.StoryIntelligenceBookPipelineID, - ActiveChapterRunId = activeSnapshot.Run.StoryIntelligenceRunID, - SessionStatus = session.Status, - OverallBookProgress = overallProgressPercent, - AuthoritativeImportProgress = authoritativeImportProgress, - CalculatedVisualisationProgress = calculatedVisualisationProgress, - TotalChapters = totalChapters, - CompletedChapters = completedChapters, - CurrentChapter = activeSnapshot.Run.ChapterNumber.HasValue ? $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}" : "Chapter unknown", - CurrentScene = CurrentSceneText(activeSnapshot), - CompletedScenes = parsedScenes, - TotalScenes = totalScenes, - ParsedScenes = parsedScenes, - TerminalRunSessionStatus = $"{session.Status}/{SessionStatus(session, snapshots)}", - DisplayedProgressReason = replayMode ? "Replay position through available saved scenes" : displayedProgressReason, - ProgressSource = replayMode ? "ReplayCursor" : "ImportSession", - CurrentBackendStage = replayMode ? "Replay saved analysis" : BackendStageDisplay(activeSnapshot.Run), - StoryMemoryCharacterCount = storyMemory.Characters.Count, - ResolvedCharacterCount = storyMemory.ResolvedCharacterCount, - UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount, - NarratorConfidence = storyMemory.NarratorConfidence, - RawScenePov = storyMemory.RawScenePov, - InheritedChapterPov = storyMemory.InheritedChapterPov, - ResolvedPovIdentity = storyMemory.ResolvedPovIdentity, - PovResolutionReason = storyMemory.PovResolutionReason, - PovConfidence = storyMemory.PovConfidence, - PovChangeDetected = storyMemory.PovChangeDetected, - SnapshotSource = replayMode ? "Replay" : "Real", - RunStatus = SessionStatus(session, snapshots), - PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count), - TotalPersistedSceneResultCount = totalPersistedResults, - SuccessfullyParsedSceneResultCount = parsedScenes, - FailedSceneResultCount = failed, - HighestPersistedSceneResultId = snapshots.SelectMany(snapshot => snapshot.Results).Select(result => (int?)result.SceneResultID).Max(), - HighestParsedSceneResultId = snapshots.SelectMany(snapshot => snapshot.Completed).Select(item => (int?)item.Result.SceneResultID).Max(), - SelectedCurrentSceneResultId = current?.Result.SceneResultID, - SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}, Scene {SceneSortNumber(current):0.##}", - CurrentChangeToken = SessionChangeToken(session, snapshots), - AnalysisCursor = FirstConfigured(activeSnapshot.Run.CurrentMessage, activeSnapshot.Run.CurrentStage, session.CurrentStage), - Warning = replayMode && failedChapterSnapshots.Count > 0 - ? $"{failedChapterSnapshots.Count:N0} failed chapter(s) skipped during replay. {failedChapterSummary}" - : warning, - ReplayIndex = replayMode ? 1 : 0, - TotalReplayableScenes = replayMode ? parsedScenes : 0, - ReplayMilestone = replayMode ? "Scene completed snapshot" : string.Empty, - ReplayState = replayMode ? "Paused at first available scene" : string.Empty, - SnapshotCacheStatus = replayMode ? "Reconstructed from saved analysis" : string.Empty, - RebuildVersion = replayMode ? "21R-replay-v1" : string.Empty, - AppearancePreference = appearancePreference, - FailedChaptersSkipped = failedChapterSnapshots.Count, - FailedChapterSummary = failedChapterSummary, - PanelVisibleCounts = replayMode ? "relationships:2; knowledge:3; observations:2; discoveries:4; activity:4" : string.Empty, - PanelHiddenCounts = replayMode ? "computed in browser per replay cursor" : string.Empty, - AiCallsDuringReplay = 0 - }; - } - - private static string ReplaySnapshotMessage(StoryIntelligenceBookPipelineState session, IReadOnlyList snapshots, int replayableScenes) - { - var failedChapters = snapshots.Count(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)); - var completedChapters = snapshots.Count(snapshot => IsSuccessfulTerminal(snapshot.Run.Status)); - return $"Saved analysis replay from Import Session {session.StoryIntelligenceBookPipelineID:N0}. {completedChapters:N0} of {snapshots.Count:N0} chapters analysed, {failedChapters:N0} failed chapter(s), {replayableScenes:N0} replayable scene(s). No AI calls are made in replay."; - } - - private static StoryIntelligenceExperienceSnapshotDiagnostics BuildDiagnostics( - StoryIntelligenceSavedRun run, - IReadOnlyList results, - IReadOnlyList parsed, - IReadOnlyList completed, - ParsedSceneResult? current, - StoryMemory storyMemory) - { - var failed = parsed.Where(item => item.Scene is null).ToList(); - var warning = failed.Count == 0 - ? null - : $"{failed.Count:N0} scene result(s) skipped because they could not be parsed."; - var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, results.Count); - var completedSceneCount = run.CompletedScenes ?? completed.Count; - var runProgress = Progress(run, completedSceneCount, totalScenes); - var calculatedProgress = OverallProgress(completed.Count, totalScenes, IsSuccessfulTerminal(run.Status)); - - return new() - { - RequestedMode = "real", - ActiveChapterRunId = run.StoryIntelligenceRunID, - SessionStatus = run.Status, - OverallBookProgress = runProgress, - AuthoritativeImportProgress = runProgress, - CalculatedVisualisationProgress = calculatedProgress, - TotalChapters = 1, - CompletedChapters = IsSuccessfulTerminal(run.Status) ? 1 : 0, - CurrentChapter = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter unknown", - CurrentScene = run.TotalDetectedScenes.HasValue - ? $"Scene {Math.Clamp(completedSceneCount + (IsTerminal(run.Status) ? 0 : 1), 1, run.TotalDetectedScenes.Value):N0} of {run.TotalDetectedScenes.Value:N0}" - : (current is null ? "Waiting" : $"Scene {SceneSortNumber(current):0.##}"), - CompletedScenes = completedSceneCount, - TotalScenes = totalScenes, - ParsedScenes = completed.Count, - TerminalRunSessionStatus = run.Status, - DisplayedProgressReason = IsSuccessfulTerminal(run.Status) ? "Successful terminal chapter run" : "Chapter run scene progress", - ProgressSource = "ChapterRun", - CurrentBackendStage = BackendStageDisplay(run), - StoryMemoryCharacterCount = storyMemory.Characters.Count, - ResolvedCharacterCount = storyMemory.ResolvedCharacterCount, - UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount, - NarratorConfidence = storyMemory.NarratorConfidence, - RawScenePov = storyMemory.RawScenePov, - InheritedChapterPov = storyMemory.InheritedChapterPov, - ResolvedPovIdentity = storyMemory.ResolvedPovIdentity, - PovResolutionReason = storyMemory.PovResolutionReason, - PovConfidence = storyMemory.PovConfidence, - PovChangeDetected = storyMemory.PovChangeDetected, - SnapshotSource = "Real", - RunStatus = run.Status, - PersistedCompletedSceneCount = run.CompletedScenes ?? 0, - TotalPersistedSceneResultCount = results.Count, - SuccessfullyParsedSceneResultCount = completed.Count, - FailedSceneResultCount = failed.Count, - HighestPersistedSceneResultId = results.Count == 0 ? null : results.Max(result => result.SceneResultID), - HighestParsedSceneResultId = completed.Count == 0 ? null : completed.Max(item => item.Result.SceneResultID), - SelectedCurrentSceneResultId = current?.Result.SceneResultID, - SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {SceneSortChapter(current):0.##}, Scene {SceneSortNumber(current):0.##}", - CurrentChangeToken = ChangeToken(run, results), - AnalysisCursor = FirstConfigured(run.CurrentMessage, run.CurrentStage), - Warning = warning - }; - } - - private static string SnapshotMessage(StoryIntelligenceSavedRun run, int completedScenes) - => run.Status switch - { - StoryIntelligenceRunStatuses.Failed => FirstConfigured(run.ErrorMessage, "Story Intelligence run failed."), - StoryIntelligenceRunStatuses.Cancelled => "Story Intelligence run was cancelled.", - StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings => "Analysis complete and ready for review.", - _ when completedScenes == 0 => "Waiting for the first persisted scene analysis.", - _ => FirstConfigured(run.CurrentMessage, "Analysis snapshot is updating.") - }; - - private async Task ApplyPersistedStoryMemoryAssignmentsAsync(int importSessionId, StoryIntelligenceExperiencePrototypeViewModel model) - { - var assignments = await storyMemoryRepository.ListAssignmentsAsync(importSessionId); - if (assignments.Count == 0) - { - return; - } - - var characters = await storyMemoryRepository.ListCharactersAsync(importSessionId); - var locations = await storyMemoryRepository.ListLocationsAsync(importSessionId); - var assets = await storyMemoryRepository.ListAssetsAsync(importSessionId); - var attributes = await storyMemoryRepository.ListCharacterAttributesAsync(importSessionId); - var importContext = await storyMemoryRepository.GetImportContextAsync(importSessionId); - var characterIds = characters.ToDictionary(item => item.StoryMemoryCharacterID, item => item.CanonicalIdentityKey); - var locationIds = locations.ToDictionary(item => item.StoryMemoryLocationID, item => item.CanonicalIdentityKey); - var assetIds = assets.ToDictionary(item => item.StoryMemoryAssetID, item => item.CanonicalIdentityKey); - var characterEvidence = characters.ToDictionary( - item => item.CanonicalIdentityKey, - item => - { - var evidence = StoryMemoryService.ResolveCharacterEvidenceFromAttributes( - item.DisplayName, - attributes.Where(attribute => attribute.StoryMemoryCharacterID == item.StoryMemoryCharacterID)); - return StoryMemoryService.ApplyImportContextToEvidence(evidence, item.DisplayName, importContext); - }, - StringComparer.OrdinalIgnoreCase); - var catalogue = (await illustrationRepository.ListAsync(new IllustrationLibraryFilter())) - .ToDictionary(item => item.IllustrationLibraryItemID); - - var characterAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Character, characterIds, catalogue, characterEvidence); - var locationAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Location, locationIds, catalogue); - var assetAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Asset, assetIds, catalogue); - - foreach (var scene in model.Scenes) - { - ApplyLocationAssignment(scene.Location, locationAssignments); - foreach (var character in scene.Characters) - { - ApplyCharacterAssignment(character, characterAssignments); - } - - foreach (var asset in scene.Assets) - { - ApplyAssetAssignment(asset, assetAssignments); - } - } - } - - private Dictionary BuildAssignmentLookup( - IReadOnlyList assignments, - string entityType, - IReadOnlyDictionary entityKeys, - IReadOnlyDictionary catalogue, - IReadOnlyDictionary? characterEvidence = null) - { - var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var assignment in assignments.Where(item => item.EntityType == entityType && item.IllustrationLibraryItemID.HasValue)) - { - if (!entityKeys.TryGetValue(assignment.EntityID, out var entityKey) - || !catalogue.TryGetValue(assignment.IllustrationLibraryItemID!.Value, out var item)) - { - continue; - } - - var publicUrl = ResolveExistingPublicUploadUrl(item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(item.FilePath); - if (string.IsNullOrWhiteSpace(publicUrl)) - { - continue; - } - - CharacterEvidenceProfile? evidence = null; - characterEvidence?.TryGetValue(entityKey, out evidence); - if (entityType == StoryMemoryEntityTypes.Character && evidence is not null) - { - var metadata = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item); - var rejections = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, true, false); - if (rejections.Count > 0) - { - continue; - } - } - - var currentEvidence = evidence is null - ? null - : $"age={evidence.AgeBand}; presentation={evidence.Presentation}; hair={evidence.HairColour}; hairLength={evidence.HairLength}; skin={evidence.SkinTone}"; - lookup[entityKey] = new AssignmentImage( - item.IllustrationLibraryItemID, - item.StableCode, - item.Status, - publicUrl, - assignment.Confidence, - assignment.MatchScore, - assignment.AssignmentReason, - evidence?.AgeBand, - evidence?.Presentation, - currentEvidence, - assignment.EvidenceVersion, - assignment.AssignmentStatus, - assignment.IsFallback); - } - - return lookup; - } - - private void ApplyCharacterAssignment(StoryIntelligenceExperienceCharacterState character, IReadOnlyDictionary assignments) - { - if (!assignments.TryGetValue(character.Id, out var assignment)) - { - return; - } - - var fallback = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl) ? character.ImagePath : character.ImageResolution.FallbackImageUrl; - character.LibraryCode = assignment.StableCode; - character.ImagePath = assignment.PublicUrl; - character.ImageResolution = BuildAssignmentResolution(character.Id, assignment, fallback, "Persisted Story Memory character assignment"); - } - - private void ApplyLocationAssignment(StoryIntelligenceExperienceLocationState location, IReadOnlyDictionary assignments) - { - if (!assignments.TryGetValue(location.Id, out var assignment)) - { - return; - } - - var fallback = string.IsNullOrWhiteSpace(location.ImageResolution.FallbackImageUrl) ? location.ImagePath : location.ImageResolution.FallbackImageUrl; - location.LibraryCode = assignment.StableCode; - location.ImagePath = assignment.PublicUrl; - location.ImageResolution = BuildAssignmentResolution(location.Id, assignment, fallback, "Persisted Story Memory location assignment"); - } - - private void ApplyAssetAssignment(StoryIntelligenceExperienceAssetState asset, IReadOnlyDictionary assignments) - { - if (!assignments.TryGetValue(asset.Id, out var assignment)) - { - return; - } - - var fallback = string.IsNullOrWhiteSpace(asset.ImageResolution.FallbackImageUrl) ? asset.ImagePath : asset.ImageResolution.FallbackImageUrl; - asset.LibraryCode = assignment.StableCode; - asset.ImagePath = assignment.PublicUrl; - asset.ImageResolution = BuildAssignmentResolution(asset.Id, assignment, fallback, "Persisted Story Memory asset assignment"); - } - - private static StoryIntelligenceExperienceImageResolution BuildAssignmentResolution(string entityId, AssignmentImage assignment, string fallback, string status) - => new() - { - EntityId = entityId, - StableCode = assignment.StableCode, - IllustrationLibraryItemId = assignment.IllustrationLibraryItemId, - Status = assignment.Status, - FinalImageUrl = assignment.PublicUrl, - FallbackImageUrl = fallback, - FallbackUsed = false, - AssignmentConfidence = assignment.Confidence, - MatchingScore = assignment.MatchScore, - ReasonChosen = assignment.Reason, - PreviousIllustration = assignment.StableCode, - CurrentEvidence = assignment.CurrentEvidence, - PreviousEvidence = $"assignment evidence version {assignment.EvidenceVersion}", - ResolvedAgeBand = assignment.ResolvedAgeBand, - ResolvedPresentation = assignment.ResolvedPresentation, - DemandStatus = "Read from durable Story Memory", - IllustrationAllocationStatus = assignment.IsFallback ? assignment.AssignmentStatus : status - }; - - private string? ResolveExistingPublicUploadUrl(string? storedPath) - { - var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library"); - return physicalPath is not null && File.Exists(physicalPath) ? storedPath : null; - } - - private static void StabiliseCharacterIllustrations(StoryIntelligenceExperiencePrototypeViewModel model) - { - var firstAssignments = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var scene in model.Scenes.OrderBy(scene => scene.SceneNumber)) - { - foreach (var character in scene.Characters) - { - if (string.IsNullOrWhiteSpace(character.Id)) - { - continue; - } - - if (firstAssignments.TryGetValue(character.Id, out var assignment)) - { - character.LibraryCode = assignment.LibraryCode; - character.ImagePath = assignment.ImagePath; - continue; - } - - firstAssignments[character.Id] = (character.LibraryCode, character.ImagePath); - } - } - } - - private static void ApplyIllustrationDiagnostics(StoryIntelligenceExperiencePrototypeViewModel model) - { - var characterResolutions = model.Scenes - .SelectMany(scene => scene.Characters) - .Select(character => character.ImageResolution) - .Where(resolution => resolution.IllustrationLibraryItemId.HasValue) - .ToList(); - model.Diagnostics.IllustrationAssignmentCount = characterResolutions - .Select(resolution => resolution.IllustrationLibraryItemId!.Value) - .Distinct() - .Count(); - model.Diagnostics.IllustrationChangeCount = characterResolutions.Count(resolution => !string.IsNullOrWhiteSpace(resolution.ReasonReassigned)); - var uniqueCharacters = model.Scenes - .SelectMany(scene => scene.Characters) - .GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .OrderBy(character => character.Name) - .ToList(); - var reuseCounts = uniqueCharacters - .Where(character => character.ImageResolution.IllustrationLibraryItemId.HasValue) - .GroupBy(character => character.ImageResolution.IllustrationLibraryItemId!.Value) - .ToDictionary(group => group.Key, group => group.Count()); - model.Diagnostics.CharacterConsistencyReport = string.Join(" | ", uniqueCharacters.Select(character => - { - var resolution = character.ImageResolution; - var reuseCount = resolution.IllustrationLibraryItemId.HasValue && reuseCounts.TryGetValue(resolution.IllustrationLibraryItemId.Value, out var count) - ? count - : 0; - var allocation = reuseCount <= 1 - ? resolution.IllustrationAllocationStatus ?? "Unique allocation" - : $"Reused {reuseCount:N0} times"; - return $"{character.Name}: gender={FirstConfigured(resolution.ResolvedPresentation, "Unknown")}, age={FirstConfigured(resolution.ResolvedAgeBand, "Unknown")}, relationship={resolution.RelationshipDerivedConfidence ?? 0:0.##}, name={resolution.NameDerivedConfidence ?? 0:0.##}, pronoun={resolution.PronounDerivedConfidence ?? 0:0.##}, illustration={FirstConfigured(resolution.StableCode, character.LibraryCode, "none")}, reuse={reuseCount:N0}, allocation={allocation}"; - })); - } - - public static void StripLiveDiagnostics(StoryIntelligenceExperiencePrototypeViewModel model) - { - model.Diagnostics.CharacterConsistencyReport = Trim(model.Diagnostics.CharacterConsistencyReport, 2000); - foreach (var resolution in model.Scenes - .SelectMany(scene => scene.Characters.Select(character => character.ImageResolution) - .Concat(scene.Assets.Select(asset => asset.ImageResolution)) - .Append(scene.Location.ImageResolution))) - { - StripLiveDiagnostics(resolution); - } - } - - private static void StripLiveDiagnostics(StoryIntelligenceExperienceImageResolution resolution) - { - if (resolution is null) - { - return; - } - - resolution.RejectedCandidates = null; - resolution.PreviousEvidence = null; - resolution.CurrentEvidence = null; - resolution.EvidenceWarnings = null; - resolution.ReasonChosen = Trim(resolution.ReasonChosen, 160); - resolution.ReasonReassigned = Trim(resolution.ReasonReassigned, 160); - resolution.PreviousIllustration = Trim(resolution.PreviousIllustration, 80); - resolution.DemandKey = Trim(resolution.DemandKey, 80); - resolution.DemandStatus = Trim(resolution.DemandStatus, 80); - } - - private static IReadOnlySet VisibleSceneResultIds( - IReadOnlyList completed, - ParsedSceneResult? current, - int previousCount = SceneWindowPrevious, - int followingCount = SceneWindowFollowing) - { - if (completed.Count == 0) - { - return new HashSet(); - } - - var currentIndex = current is null - ? completed.Count - 1 - : completed.ToList().FindIndex(item => item.Result.SceneResultID == current.Result.SceneResultID); - if (currentIndex < 0) - { - currentIndex = completed.Count - 1; - } - - var start = Math.Max(0, currentIndex - previousCount); - var end = Math.Min(completed.Count - 1, currentIndex + followingCount); - return completed - .Skip(start) - .Take(end - start + 1) - .Select(item => item.Result.SceneResultID) - .ToHashSet(); - } - - private void LogPayload(StoryIntelligenceExperiencePrototypeViewModel model, string scope, long elapsedMs) - { - var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(model, JsonOptions).Length; - var counts = new - { - sceneCount = model.Scenes.Count, - characterCount = model.Scenes.Sum(scene => scene.Characters.Count), - locationCount = model.Scenes.Count(scene => !string.IsNullOrWhiteSpace(scene.Location.Name)), - assetCount = model.Scenes.Sum(scene => scene.Assets.Count), - relationshipCount = model.Scenes.Sum(scene => scene.Relationships.Count), - knowledgeCount = model.Scenes.Sum(scene => scene.KnowledgeThreads.Count), - observationCount = model.Scenes.Sum(scene => scene.Observations.Count), - activityCount = model.Scenes.Sum(scene => scene.ActivityFeed.Count) - }; - var largestString = LargestStringProperty(model); - if (payloadBytes > SnapshotWarningBytes) - { - logger.LogWarning( - "Story Intelligence snapshot payload for {Scope} is {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters; built in {ElapsedMs}ms.", - scope, - payloadBytes, - counts, - largestString.Path, - largestString.Length, - elapsedMs); - return; - } - - logger.LogInformation( - "Built Story Intelligence visualisation snapshot for {Scope} in {ElapsedMs}ms; payload {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters.", - scope, - elapsedMs, - payloadBytes, - counts, - largestString.Path, - largestString.Length); - } - - private void LogSnapshotSynchronisation( - int importSessionId, - RunSnapshot activeSnapshot, - ParsedSceneResult? current, - IReadOnlyList scenes, - string changeToken) - { - var displayed = scenes.FirstOrDefault(scene => scene.IsCurrent) ?? scenes.LastOrDefault(); - logger.LogInformation( - "Story Intelligence snapshot synchronisation: import session {ImportSessionId}; active run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; selected scene result {SceneResultId}; displayed scene id {DisplayedSceneId}; change token {ChangeToken}; payload scenes {SceneIds}.", - importSessionId, - activeSnapshot.Run.StoryIntelligenceRunID, - activeSnapshot.Run.ChapterNumber, - current is null ? null : SceneSortNumber(current), - current?.Result.SceneResultID, - displayed?.Id, - changeToken, - string.Join(",", scenes.Select(scene => scene.Id))); - } - - private static (string Path, int Length) LargestStringProperty(StoryIntelligenceExperiencePrototypeViewModel model) - { - var largest = ("model", 0); - void Consider(string path, string? value) - { - if (value?.Length > largest.Item2) - { - largest = (path, value.Length); - } - } - - Consider("snapshotMessage", model.SnapshotMessage); - Consider("diagnostics.characterConsistencyReport", model.Diagnostics.CharacterConsistencyReport); - foreach (var scene in model.Scenes) - { - Consider($"{scene.Id}.summary", scene.Summary); - foreach (var character in scene.Characters) - { - Consider($"{scene.Id}.character.{character.Id}.name", character.Name); - Consider($"{scene.Id}.character.{character.Id}.relevance", character.Relevance); - Consider($"{scene.Id}.character.{character.Id}.rejectedCandidates", character.ImageResolution.RejectedCandidates); - } - } - - return (largest.Item1, largest.Item2); - } - - private static bool IsTerminal(string status) - => status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled; - - private static bool IsSuccessfulTerminal(string status) - => status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings; - - private static bool IsSuccessfulSession(StoryIntelligenceBookPipelineState session) - => string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) - && (string.Equals(session.LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase) - || string.Equals(session.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase) - || session.CompletedUtc.HasValue); - - private static bool IsTerminalSession(StoryIntelligenceBookPipelineState session) - => IsSuccessfulSession(session); - - private static int SceneNumber(ParsedSceneResult item) - => item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber; - - private static decimal SceneSortChapter(ParsedSceneResult item) - => item.Scene?.SceneReference?.ChapterNumber - ?? (item.Result.ChapterID.HasValue ? item.Result.ChapterID.Value : 0); - - private static decimal SceneSortNumber(ParsedSceneResult item) - => item.Scene?.SceneReference?.SceneNumber ?? item.Result.TemporarySceneNumber; - - private static string SceneTitle(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result, int sceneNumber) - => CompactSceneTitle(FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}"), sceneNumber); - - private static string CompactSceneTitle(string title, int sceneNumber) - { - var value = Clean(title); - if (string.IsNullOrWhiteSpace(value)) - { - return $"Scene {sceneNumber:N0}"; - } - - var sceneMarker = $"scene {sceneNumber}".ToLowerInvariant(); - var lower = value.ToLowerInvariant(); - var markerIndex = lower.IndexOf(sceneMarker, StringComparison.Ordinal); - if (markerIndex > 0) - { - value = value[markerIndex..].Trim(' ', '-', ':', '|'); - } - - var separators = new[] { " - ", " | ", ": " }; - foreach (var separator in separators) - { - var parts = value.Split(separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length >= 3) - { - return Trim(parts[^1], 72); - } - } - - return Trim(value, 72); - } - - private static string TimelineLabel(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result) - => FirstConfigured(scene.TimelineClues?.FirstOrDefault()?.AbsoluteDate, scene.Setting?.DateOrTimeReference, result.SourceLabel, "Known scene"); - - private static string ChapterLabel(StoryIntelligenceSavedRun run, ParsedSceneResult item) - { - var chapter = item.Scene?.SceneReference?.ChapterNumber ?? run.ChapterNumber; - return chapter.HasValue ? $"Chapter {chapter:0.##}" : "Chapter"; - } - - private static int Progress(StoryIntelligenceSavedRun run, int completedScenes, int totalScenes) - { - if (IsTerminal(run.Status) && run.Status is not StoryIntelligenceRunStatuses.Failed) - { - return 100; - } - - if (totalScenes > 0) - { - return Math.Clamp((int)Math.Round(completedScenes * 100m / totalScenes), 0, 99); - } - - return run.Status == StoryIntelligenceRunStatuses.Running ? 8 : 0; - } - - private static int OverallProgress(int completedScenes, int totalScenes, bool isTerminal) - { - if (isTerminal) - { - return 100; - } - - if (totalScenes > 0) - { - return Math.Clamp((int)Math.Round(completedScenes * 100m / totalScenes), 0, 99); - } - - return 0; - } - - private static int BookProgress(IReadOnlyList snapshots, int chapterTotal, bool isTerminal) - { - if (isTerminal) - { - return 100; - } - - if (chapterTotal <= 0) - { - return 0; - } - - var chapterProgress = snapshots.Sum(ChapterProgress); - return Math.Clamp((int)Math.Floor(chapterProgress * 100m / chapterTotal), 0, 99); - } - - private static decimal ChapterProgress(RunSnapshot snapshot) - { - if (IsSuccessfulTerminal(snapshot.Run.Status)) - { - return 1m; - } - - if (snapshot.Run.Status == StoryIntelligenceRunStatuses.Pending) - { - return 0m; - } - - if ((snapshot.Run.TotalDetectedScenes ?? 0) > 0) - { - var sceneProgress = Math.Clamp((snapshot.Run.CompletedScenes ?? snapshot.Completed.Count) / Math.Max(1m, snapshot.Run.TotalDetectedScenes ?? 1), 0m, 1m); - return Math.Clamp(0.25m + (sceneProgress * 0.7m), 0m, 0.95m); - } - - return 0.15m; - } - - private static string EstimateRemaining(StoryIntelligenceSavedRun run, int completedScenes, int totalScenes) - { - if (IsTerminal(run.Status)) - { - return run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings ? "Ready to review" : run.Status; - } - - if (completedScenes <= 0 || totalScenes <= completedScenes) - { - return "Calculating"; - } - - var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds)); - var averageMs = elapsedMs / Math.Max(1, completedScenes); - return FormatDuration(averageMs * (totalScenes - completedScenes)); - } - - private static string EstimateElapsed(StoryIntelligenceSavedRun run) - { - var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds)); - return FormatDuration(elapsedMs); - } - - private static long SessionElapsedMilliseconds(IReadOnlyList snapshots, bool isTerminal) - { - if (snapshots.Count == 0) - { - return 0; - } - - var started = snapshots.Min(snapshot => snapshot.Run.StartedUtc); - var ended = isTerminal - ? snapshots - .Select(snapshot => snapshot.Run.CompletedUtc ?? snapshot.Run.UpdatedUtc) - .DefaultIfEmpty(DateTime.UtcNow) - .Max() - : DateTime.UtcNow; - return Math.Max(0, Convert.ToInt64((ended - started).TotalMilliseconds)); - } - - private static string FormatDuration(long milliseconds) - { - var duration = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds)); - return duration.TotalMinutes >= 1 ? $"{Math.Max(1, (int)Math.Round(duration.TotalMinutes)):N0} min" : $"{Math.Max(0, (int)Math.Round(duration.TotalSeconds)):N0} sec"; - } - - private static int ChapterCount(IEnumerable completed) - => Math.Max(1, completed.Select(item => item.Result.ChapterID ?? 0).Where(id => id > 0).Distinct().Count()); - - private static string StageDisplay(StoryIntelligenceSavedRun run) - => FirstConfigured(run.CurrentStage, run.Status); - - private static StoryIntelligenceExperienceLocationState PendingLocation() - => new() - { - Id = "location-pending", - LibraryCode = string.Empty, - Name = "Location not yet identified", - Label = "Awaiting scene location", - ImagePath = $"{FallbackRoot}/location-flat.svg" - }; - - private static string CharacterId(string? name) => $"character-result-{StableKey(CanonicalCharacterIdentity(name))}"; - - private static string CanonicalCharacterIdentity(string? name) - { - var clean = Clean(name).ToLowerInvariant(); - if (string.IsNullOrWhiteSpace(clean)) - { - return "unknown"; - } - - var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (words.Length == 2 - && !IsTitleWord(words[0]) - && name is not null - && name.Trim().Length > 0 - && char.IsUpper(name.Trim()[0]) - && name.Trim().All(ch => char.IsLetter(ch) || char.IsWhiteSpace(ch) || ch == '\'' || ch == '\u2019')) - { - return words[0]; - } - - if (clean is "mother" or "mum" or "mummy" or "her mother" or "the mother" or "my mother" or "my mum" - || clean.Contains("beth's mum", StringComparison.Ordinal) - || clean.Contains("beth's mother", StringComparison.Ordinal)) - { - return "beth mother"; - } - - if (clean is "aunt" or "her aunt" or "my aunt" or "aunt elen" || clean.Contains("beth's aunt", StringComparison.Ordinal)) - { - return "beth aunt"; - } - - if (IsGenericNarratorName(clean)) - { - return "narrator"; - } - - return clean; - } - - private static bool IsTitleWord(string value) - => value is "mr" or "mrs" or "miss" or "ms" or "dr" or "doctor" or "sir" or "lady"; - - private static int CharacterWeight(SceneIntelligenceCharacter character) - => character.MentionedOnly == true ? 46 : ConfidenceWeight(character.Confidence, 72); - - private static int AssetWeight(SceneIntelligenceAsset asset) - => ConfidenceWeight(asset.Confidence, string.IsNullOrWhiteSpace(asset.Status) ? 64 : 82); - - private static int ConfidenceWeight(decimal? confidence, int fallback) - => Math.Clamp(confidence.HasValue ? (int)Math.Round(confidence.Value * 100m) : fallback, 30, 100); - - private static int Specificity(string? value) - => string.IsNullOrWhiteSpace(value) ? 0 : value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; - - private static bool IsLowValueAsset(SceneIntelligenceAsset asset) - { - var value = $"{asset.Name} {asset.AssetType} {asset.Notes}".ToLowerInvariant(); - return value.Contains("background", StringComparison.OrdinalIgnoreCase) - || value.Contains("ordinary", StringComparison.OrdinalIgnoreCase) - || value is "door" or "table" or "chair"; - } - - private static string CharacterLibraryCode( - string name, - SceneIntelligenceCharacter? character, - SceneIntelligenceScene? scene = null, - IReadOnlySet? reservedCodes = null) - { - var profile = CharacterProfile.From(name, character, scene); - var candidates = CharacterCodeProfiles - .OrderByDescending(candidate => candidate.Score(profile)) - .ThenBy(candidate => reservedCodes?.Contains(candidate.Code) == true ? 1 : 0) - .ThenBy(candidate => StableHash($"{name}|{candidate.Code}")) - .ToList(); - - return candidates.FirstOrDefault(candidate => reservedCodes?.Contains(candidate.Code) != true)?.Code - ?? candidates.First().Code; - } - - private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting) - { - var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant(); - var type = StoryIntelligenceIllustrationCompatibility.LocationType(value); - if (type == "PublicToilet") return "loc-generic-public-toilet"; - if (type == "Bathroom") return "loc-generic-bathroom"; - if (type == "Kitchen") return "loc-generic-kitchen"; - if (type == "WaitingRoom") return "loc-generic-waiting-room"; - if (type == "DrivingTestCentre") return "loc-driving-test-centre"; - if (type == "VehicleInterior") return "loc-vehicle-interior"; - if (type is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" or "Motorway" or "Bridge" or "Flyover" or "Underpass") return "loc-generic-road"; - if (type == "Garage") return "loc-garage-workshop"; - if (type is "Hospital" or "PoliceStation" or "School" or "Church" or "Restaurant" or "Pub") return $"loc-generic-{StableKey(type)}"; - if (type is "Kitchen" or "Bedroom" or "LivingRoom" or "FlatInterior" or "Hallway") return "loc-small-flat-interior"; - if (type == "Office") return "loc-domestic-desk"; - if (type == "Stairwell") return "loc-narrow-stairwell"; - if (type is "CarPark") return "loc-wet-car-park"; - if (type == "HouseExterior") return "loc-estate-house-exterior"; - return string.Empty; - } - - private static string AssetLibraryCode(string name, SceneIntelligenceAsset asset) - { - var value = $"{name} {asset.AssetType} {asset.Status}".ToLowerInvariant(); - var type = StoryIntelligenceIllustrationCompatibility.AssetType(value); - if (type is "Letter" or "Note" or "Document" or "Certificate" or "Passport" or "DrivingLicence") return "asset-document"; - if (type is "Envelope") return "asset-envelope"; - if (type is "Notebook") return "asset-worn-notebook"; - if (type is "Book" or "DrivingManual" or "Newspaper" or "Magazine") return "asset-book"; - if (type == "Car") return "asset-red-classic-car"; - if (type == "Keys") return "asset-old-keys"; - if (type == "Telephone") return "asset-phone-recorder"; - if (type == "Photograph") return "asset-photo-print"; - if (type is "Painting") return "asset-photo-print"; - if (type == "Bottle") return "asset-bottle"; - if (type == "Clock") return "asset-clock"; - if (type == "TrafficSign") return "asset-traffic-sign"; - if (type is "Wallet" or "Handbag" or "Suitcase") return "asset-bag-wallet"; - if (type is "Parcel" or "Gift") return "asset-parcel"; - if (type is "Bench") return "asset-bench"; - if (type is "Tree" or "Lamp" or "Window" or "Door" or "Fence") return "asset-environment-object"; - if (type == "Rucksack") return "asset-rucksack"; - return string.Empty; - } - - private static string CharacterFallback(string name) - => CharacterLibraryCode(name, null) switch - { - "char-young-adult-red-haired-witness" => $"{FallbackRoot}/character-rosie.svg", - "char-middle-aged-weathered-man" => $"{FallbackRoot}/character-graham.svg", - "char-older-soft-presence" => $"{FallbackRoot}/character-maggie.svg", - "char-young-helper" => $"{FallbackRoot}/character-beth.svg", - "char-archival-contact" => $"{FallbackRoot}/character-graham.svg", - "char-professional-investigator" => $"{FallbackRoot}/character-graham.svg", - "char-neighbourhood-friend" => $"{FallbackRoot}/character-beth.svg", - "char-family-relative" => $"{FallbackRoot}/character-maggie.svg", - "char-quiet-antagonist" => $"{FallbackRoot}/character-graham.svg", - "char-authorial-voice" => $"{FallbackRoot}/character-maggie.svg", - "char-unknown-figure" => $"{FallbackRoot}/character-beth.svg", - _ => $"{FallbackRoot}/character-beth.svg" - }; - - private static string LocationFallback(string name) - => LocationLibraryCode(name, null, null) switch - { - "loc-generic-public-toilet" => string.Empty, - "loc-laundry-utility-room" => $"{FallbackRoot}/location-laundry.svg", - "loc-narrow-stairwell" => $"{FallbackRoot}/location-stairwell.svg", - "loc-wet-car-park" => $"{FallbackRoot}/location-car-park.svg", - "loc-estate-house-exterior" => $"{FallbackRoot}/location-doweries.svg", - "loc-generic-road" => $"{FallbackRoot}/location-road.svg", - "loc-vehicle-interior" => $"{FallbackRoot}/location-vehicle-interior.svg", - "loc-generic-bathroom" => $"{FallbackRoot}/location-bathroom.svg", - "loc-generic-kitchen" => $"{FallbackRoot}/location-kitchen.svg", - "loc-generic-waiting-room" => $"{FallbackRoot}/location-waiting-room.svg", - "loc-driving-test-centre" => $"{FallbackRoot}/location-driving-test-centre.svg", - "loc-garage-workshop" => $"{FallbackRoot}/location-garage-workshop.svg", - "loc-generic-hospital" => $"{FallbackRoot}/location-building.svg", - "loc-generic-policestation" => $"{FallbackRoot}/location-building.svg", - "loc-generic-school" => $"{FallbackRoot}/location-building.svg", - "loc-generic-church" => $"{FallbackRoot}/location-building.svg", - "loc-generic-restaurant" => $"{FallbackRoot}/location-building.svg", - "loc-generic-pub" => $"{FallbackRoot}/location-building.svg", - _ => $"{FallbackRoot}/location-flat.svg" - }; - - private static string AssetFallback(string name) - => AssetLibraryCode(name, new SceneIntelligenceAsset()) switch - { - "asset-folded-letter" => $"{FallbackRoot}/asset-letter.svg", - "asset-document" => $"{FallbackRoot}/asset-document.svg", - "asset-envelope" => $"{FallbackRoot}/asset-envelope.svg", - "asset-worn-notebook" => $"{FallbackRoot}/asset-notebook.svg", - "asset-book" => $"{FallbackRoot}/asset-book.svg", - "asset-red-classic-car" => $"{FallbackRoot}/asset-red-tr6.svg", - "asset-old-keys" => $"{FallbackRoot}/asset-keys.svg", - "asset-phone-recorder" => $"{FallbackRoot}/asset-phone.svg", - "asset-photo-print" => $"{FallbackRoot}/asset-photo.svg", - "asset-bottle" => $"{FallbackRoot}/asset-bottle.svg", - "asset-clock" => $"{FallbackRoot}/asset-clock.svg", - "asset-traffic-sign" => $"{FallbackRoot}/asset-traffic-sign.svg", - "asset-bag-wallet" => $"{FallbackRoot}/asset-bag-wallet.svg", - "asset-parcel" => $"{FallbackRoot}/asset-parcel.svg", - "asset-bench" => $"{FallbackRoot}/asset-bench.svg", - "asset-environment-object" => $"{FallbackRoot}/asset-environment.svg", - _ => $"{FallbackRoot}/asset-generic.svg" - }; - - private static string StableKey(string? value) - { - var clean = Clean(value).ToLowerInvariant(); - if (string.IsNullOrWhiteSpace(clean)) - { - return "unknown"; - } - - var builder = new StringBuilder(); - foreach (var ch in clean) - { - if (char.IsLetterOrDigit(ch)) - { - builder.Append(ch); - } - else if (builder.Length > 0 && builder[^1] != '-') - { - builder.Append('-'); - } - } - - return builder.ToString().Trim('-'); - } - - private static int StableHash(string? value) - { - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)); - return BitConverter.ToInt32(bytes, 0) & int.MaxValue; - } - - private static string StableContentHash(string? value) - => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)))[..16].ToLowerInvariant(); - - private static string Clean(string? value) - => string.Join(' ', (value ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Trim(); - - private static string FirstConfigured(params string?[] values) - => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; - - private static string DisplayTitle(string? value, string fallback) - => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); - - private static string Trim(string? value, int max) - { - var clean = Clean(value); - return clean.Length <= max ? clean : clean[..Math.Max(0, max - 3)].TrimEnd() + "..."; - } - - private sealed record RunSnapshot( - StoryIntelligenceSavedRun Run, - IReadOnlyList Results, - IReadOnlyList Parsed, - IReadOnlyList Completed, - ParsedSceneResult? Current); - - private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError); - - private sealed record AssignmentImage( - int IllustrationLibraryItemId, - string StableCode, - string Status, - string PublicUrl, - decimal Confidence, - int MatchScore, - string? Reason, - string? ResolvedAgeBand, - string? ResolvedPresentation, - string? CurrentEvidence, - string EvidenceVersion, - string AssignmentStatus, - bool IsFallback); - - private sealed record CharacterCodeProfile( - string Code, - string AgeBand, - string Presentation, - string HairColour, - string HairLength, - IReadOnlyList Signals) - { - public int Score(CharacterProfile profile) - { - var score = 0; - if (profile.AgeBand == AgeBand) score += 80; - else if (CompatibleAge(profile.AgeBand, AgeBand)) score += 30; - else score -= 45; - - if (!string.IsNullOrWhiteSpace(profile.Presentation) && Presentation.Equals(profile.Presentation, StringComparison.OrdinalIgnoreCase)) score += 24; - if (!string.IsNullOrWhiteSpace(profile.HairColour) && HairColour.Equals(profile.HairColour, StringComparison.OrdinalIgnoreCase)) score += 18; - if (!string.IsNullOrWhiteSpace(profile.HairLength) && HairLength.Equals(profile.HairLength, StringComparison.OrdinalIgnoreCase)) score += 10; - score += Signals.Count(signal => profile.Text.Contains(signal, StringComparison.OrdinalIgnoreCase)) * 14; - return score; - } - - private static bool CompatibleAge(string required, string candidate) - => (required, candidate) switch - { - ("Teen", "YoungAdult") => true, - ("YoungAdult", "Teen") => true, - ("MiddleAged", "Older") => true, - ("Older", "MiddleAged") => true, - _ => false - }; - } - - private sealed record CharacterProfile(string Text, string AgeBand, string Presentation, string HairColour, string HairLength) - { - public static CharacterProfile From(string name, SceneIntelligenceCharacter? character, SceneIntelligenceScene? scene) - { - var relationshipText = string.Join(' ', (scene?.Relationships ?? []) - .Where(relationship => string.Equals(Clean(relationship.CharacterA), Clean(name), StringComparison.OrdinalIgnoreCase) - || string.Equals(Clean(relationship.CharacterB), Clean(name), StringComparison.OrdinalIgnoreCase)) - .Select(relationship => $"{relationship.RelationshipSignal} {relationship.Evidence}")); - var text = $"{name} {character?.RoleInScene} {character?.Notes} {string.Join(' ', character?.Actions ?? [])} {relationshipText}".ToLowerInvariant(); - return new( - text, - DetectAgeBand(text), - DetectPresentation(text), - DetectHairColour(text), - DetectHairLength(text)); - } - - private static string DetectAgeBand(string text) - { - if (text.Contains("grandmother") || text.Contains("grandfather") || text.Contains("elderly") || text.Contains("older") || text.Contains("retired")) return "Older"; - if (text.Contains("mother") || text.Contains("father") || text.Contains("parent") || text.Contains("teacher") || text.Contains("aunt") || text.Contains("uncle") || text.Contains("middle-aged") || text.Contains("middle aged")) return "MiddleAged"; - if (text.Contains("teen") || text.Contains("teenage") || text.Contains("15") || text.Contains("fifteen") || text.Contains("schoolgirl") || text.Contains("schoolboy") || text.Contains("child") || text.Contains("daughter") || text.Contains("son")) return "Teen"; - if (text.Contains("young") || text.Contains("student")) return "YoungAdult"; - return "YoungAdult"; - } - - private static string DetectPresentation(string text) - { - if (text.Contains("mother") || text.Contains("daughter") || text.Contains("woman") || text.Contains("girl") || text.Contains("female") || text.Contains("sister") || text.Contains("aunt")) return "Feminine"; - if (text.Contains("father") || text.Contains("son") || text.Contains("man") || text.Contains("male") || text.Contains("brother") || text.Contains("uncle")) return "Masculine"; - return string.Empty; - } - - private static string DetectHairColour(string text) - { - if (text.Contains("red") || text.Contains("auburn") || text.Contains("ginger")) return "Red"; - if (text.Contains("grey") || text.Contains("gray") || text.Contains("white-haired") || text.Contains("white hair")) return "Grey"; - if (text.Contains("black hair") || text.Contains("black-haired")) return "Black"; - if (text.Contains("dark") || text.Contains("brown") || text.Contains("brunette")) return "Brown"; - return string.Empty; - } - - private static string DetectHairLength(string text) - { - if (text.Contains("long hair") || text.Contains("long-haired")) return "Long"; - if (text.Contains("short hair") || text.Contains("short-haired")) return "Short"; - return string.Empty; - } - } - - private static readonly IReadOnlyList CharacterCodeProfiles = - [ - new("char-young-helper", "Teen", "Androgynous", "Black", "Short", ["teen", "child", "daughter", "son", "helper"]), - new("char-young-adult-red-haired-witness", "YoungAdult", "Feminine", "Red", "Long", ["red", "auburn", "witness", "protective"]), - new("char-young-adult-brunette-observer", "YoungAdult", "Feminine", "Brown", "Medium", ["young", "observer", "student"]), - new("char-neighbourhood-friend", "YoungAdult", "Androgynous", "Brown", "Medium", ["friend", "neighbour", "neighbor"]), - new("char-family-relative", "MiddleAged", "Feminine", "Brown", "Medium", ["mother", "parent", "aunt", "family", "relative"]), - new("char-professional-investigator", "MiddleAged", "Androgynous", "Brown", "Short", ["teacher", "professional", "investigator", "authority"]), - new("char-middle-aged-weathered-man", "MiddleAged", "Masculine", "DarkBrown", "Short", ["father", "man", "suspect", "threat", "weathered"]), - new("char-quiet-antagonist", "MiddleAged", "Androgynous", "DarkBrown", "Short", ["antagonist", "threat", "pressure", "controlled"]), - new("char-older-soft-presence", "Older", "Feminine", "Grey", "Short", ["grandmother", "older", "remembered", "mother"]), - new("char-archival-contact", "Older", "Masculine", "White", "Short", ["grandfather", "older", "archive", "records"]), - new("char-authorial-voice", "Older", "Androgynous", "Grey", "Medium", ["voice", "memory", "recorded"]), - new("char-unknown-figure", "YoungAdult", "Androgynous", "Brown", "Medium", ["unknown", "unidentified"]) - ]; -} diff --git a/PlotLine/Sql/149_Phase24_LeanImportCompletionNotifications.sql b/PlotLine/Sql/149_Phase24_LeanImportCompletionNotifications.sql new file mode 100644 index 0000000..7be9df2 --- /dev/null +++ b/PlotLine/Sql/149_Phase24_LeanImportCompletionNotifications.sql @@ -0,0 +1,122 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'CompletionEmailQueuedUtc') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceBookPipelines + ADD CompletionEmailQueuedUtc datetime2 NULL; +END; +GO + +IF COL_LENGTH(N'dbo.StoryIntelligenceBookPipelines', N'FailureEmailQueuedUtc') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceBookPipelines + ADD FailureEmailQueuedUtc datetime2 NULL; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_TryBeginCompletionNotification + @BookID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @ImportSessionID int; + DECLARE @ProjectID int; + DECLARE @HasActiveRuns bit; + DECLARE @HasFailedRuns bit; + DECLARE @ChapterCount int; + DECLARE @CompletedChapterCount int; + DECLARE @FailedChapterCount int; + DECLARE @NotificationKind nvarchar(20); + + SELECT @ImportSessionID = p.StoryIntelligenceBookPipelineID, + @ProjectID = p.ProjectID + FROM dbo.StoryIntelligenceBookPipelines p + INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = p.ProjectID + AND access.UserID = @UserID + AND access.IsActive = 1 + WHERE p.BookID = @BookID; + + IF @ImportSessionID IS NULL + RETURN; + + SELECT @HasActiveRuns = CASE WHEN EXISTS + ( + SELECT 1 + FROM dbo.StoryIntelligenceRuns + WHERE BookID = @BookID + AND UserID = @UserID + AND Status IN (N'Pending', N'Running') + ) THEN 1 ELSE 0 END; + + IF @HasActiveRuns = 1 + RETURN; + + SELECT @ChapterCount = COUNT(1), + @CompletedChapterCount = SUM(CASE WHEN Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END), + @FailedChapterCount = SUM(CASE WHEN Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END), + @HasFailedRuns = CASE WHEN SUM(CASE WHEN Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END + FROM dbo.StoryIntelligenceRuns + WHERE BookID = @BookID + AND UserID = @UserID; + + IF COALESCE(@ChapterCount, 0) = 0 + RETURN; + + SET @NotificationKind = CASE WHEN @HasFailedRuns = 1 THEN N'Failure' ELSE N'Success' END; + + BEGIN TRANSACTION; + + IF @NotificationKind = N'Failure' + BEGIN + UPDATE dbo.StoryIntelligenceBookPipelines + SET FailureEmailQueuedUtc = SYSUTCDATETIME(), + UpdatedUtc = SYSUTCDATETIME() + WHERE StoryIntelligenceBookPipelineID = @ImportSessionID + AND FailureEmailQueuedUtc IS NULL; + + IF @@ROWCOUNT = 0 + BEGIN + ROLLBACK TRANSACTION; + RETURN; + END; + END + ELSE + BEGIN + UPDATE dbo.StoryIntelligenceBookPipelines + SET CompletionEmailQueuedUtc = SYSUTCDATETIME(), + UpdatedUtc = SYSUTCDATETIME() + WHERE StoryIntelligenceBookPipelineID = @ImportSessionID + AND CompletionEmailQueuedUtc IS NULL; + + IF @@ROWCOUNT = 0 + BEGIN + ROLLBACK TRANSACTION; + RETURN; + END; + END; + + COMMIT TRANSACTION; + + SELECT @ImportSessionID AS ImportSessionID, + @UserID AS UserID, + p.ProjectID, + project.ProjectName, + p.BookID, + book.BookTitle, + book.Subtitle AS BookSubtitle, + @ChapterCount AS ChapterCount, + @CompletedChapterCount AS CompletedChapterCount, + @FailedChapterCount AS FailedChapterCount, + @NotificationKind AS NotificationKind + FROM dbo.StoryIntelligenceBookPipelines p + INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID + INNER JOIN dbo.Books book ON book.BookID = p.BookID + WHERE p.StoryIntelligenceBookPipelineID = @ImportSessionID; +END; +GO diff --git a/PlotLine/Sql/150_Phase24B_RemoveVisualisationIllustrationAssignments.sql b/PlotLine/Sql/150_Phase24B_RemoveVisualisationIllustrationAssignments.sql new file mode 100644 index 0000000..2ff3a96 --- /dev/null +++ b/PlotLine/Sql/150_Phase24B_RemoveVisualisationIllustrationAssignments.sql @@ -0,0 +1,10 @@ +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO + +IF OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments; +END +GO diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml index 5c767e1..4210595 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml @@ -141,11 +141,6 @@ { Continue in background } - @if (Model.ImportSessionID.HasValue) - { - Replay Story Intelligence - } @if (!Model.HasActiveRuns && Model.Chapters.Any(chapter => chapter.IsRunComplete || chapter.IsFailed || chapter.HasCompletedCommit)) { persisted results and domain progress + -> read-only visualisation +``` + +The visualisation must not cause extra analysis, extraction, persistence, AI calls, or image generation just to make the display interesting. + +This audit is based on source inspection only. No application code, SQL, or runtime data was changed. Screenshots were not captured because doing that safely would require an authenticated development session and existing import data; the existing design reference is `/srv/repos/PlotDirector/PlotLine/Docs/Features/PlotDirector_Story_Intelligence_Visualisation_Design.png`. + +## 2. Current UX + +There are two related experiences: + +- Active onboarding progress page: `/srv/repos/PlotDirector/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml`. This is the production-facing progress page. It shows "Reading your manuscript", a plain import progress card, chapters read, scenes read, failures, elapsed time, estimated remaining, current chapter, current scene, latest scene summary, recent discoveries, cancel, continue in background, replay, and review actions. +- Development experience visualisation: `/srv/repos/PlotDirector/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml`. This is admin/development-only and uses the full visual theatre: dark full-screen shell, header status strip, circular progress ring, left-side discovery/activity panels, central "living story view", character/location stage, SVG relationship lines, scene ribbon, timeline, right-side knowledge/observation/relationship panels, replay controls, and diagnostics. + +The production page explicitly says processing can continue after the browser tab is closed. The full visualisation is reachable from the production progress page by a "Replay Story Intelligence" link when an import session exists, but the controller route is protected by `[Authorize(Policy = "AdminOnly")]` and `environment.IsDevelopment()`. + +The distinctive visual language lives mostly in `/srv/repos/PlotDirector/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css`: dark navy/blue-gold stage, blurred translucent panels, conic progress ring, animated SVG connection lines, circular character/location portraits, text-only evidence cards when portraits are not trusted, glowing timeline markers, compact side panels, and reduced-motion handling. Client behaviour is in `/srv/repos/PlotDirector/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js`: it polls snapshots, reconciles nodes, animates progress, preloads images, draws collision-aware connections, updates scene ribbon/timeline, and opens insight detail cards. + +## 3. Architecture + +The active import pipeline is separate from the full visualisation: + +- `/srv/repos/PlotDirector/PlotLine/Services/OnboardingStoryIntelligenceService.cs` queues one persisted Story Intelligence run per included chapter. +- `/srv/repos/PlotDirector/PlotLine/Services/PersistedStoryIntelligenceWorker.cs` is the background service that repeatedly asks the runner to process pending runs. +- `/srv/repos/PlotDirector/PlotLine/Services/PersistedStoryIntelligenceRunner.cs` performs the expensive work: one Chapter Structure call, one Scene Intelligence call per detected scene, JSON repair when needed, validation, persistence, progress publication, Story Memory processing, and Character Intelligence profile updates. +- `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs` builds read models from persisted run/scene results and Story Memory assignments. +- `/srv/repos/PlotDirector/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs` exposes read-only JSON snapshots for authenticated users. +- `/srv/repos/PlotDirector/PlotLine/Controllers/DevelopmentController.cs` serves the full experience page and its replay/rebuild action, development-only. +- `/srv/repos/PlotDirector/PlotLine/Hubs/StoryIntelligenceHub.cs` plus `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceProgressNotifier.cs` provide reusable SignalR progress updates. + +The full visualisation is mostly a read-only observer when simply opened. The dangerous coupling is that Story Memory and illustration-demand machinery can be used by the import pipeline and by the development replay/rebuild path, so "visualisation support" has grown into durable derived state. + +## 4. Component Inventory + +| Component | Classification | Notes | +|---|---|---| +| `/srv/repos/PlotDirector/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs` | VISUALISATION ONLY | Read-only snapshot API for run/import session visualisation. | +| `/srv/repos/PlotDirector/PlotLine/Controllers/DevelopmentController.cs` methods `StoryIntelligenceExperience`, `RebuildStoryIntelligenceExperience`, diagnostics | VISUALISATION ONLY / UNCERTAIN | Development-only UI and rebuild tools. Rebuild calls Story Memory and can create demand records. | +| `/srv/repos/PlotDirector/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml` | VISUALISATION ONLY | Full-screen experience/replay UI. | +| `/srv/repos/PlotDirector/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js` | VISUALISATION ONLY | Snapshot polling, animation, layout, drawing, replay controls. | +| `/srv/repos/PlotDirector/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css` | VISUALISATION ONLY | Full visual design system for the prototype. | +| `/srv/repos/PlotDirector/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs` | VISUALISATION ONLY | Snapshot DTOs: scenes, characters, locations, assets, relationships, diagnostics, image resolution. | +| `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs` | DO NOT REMOVE UNTIL DECOUPLED | Builds visualization snapshots and contains visual-only inference/story-memory logic. | +| `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceExperiencePrototypeData.cs` | VISUALISATION ONLY | Simulation data. | +| `/srv/repos/PlotDirector/PlotLine/wwwroot/images/story-intelligence/prototype/*` | VISUALISATION ONLY | Static fallback prototype assets. | +| `/srv/repos/PlotDirector/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml` | IMPORT PIPELINE / SHARED UX | Active import status screen; contains replay link. | +| `/srv/repos/PlotDirector/PlotLine/wwwroot/js/story-intelligence-progress.js` | SHARED INFRASTRUCTURE | Production progress SignalR client for onboarding and legacy dashboards. | +| `/srv/repos/PlotDirector/PlotLine/Hubs/StoryIntelligenceHub.cs` | SHARED INFRASTRUCTURE | Authenticated SignalR hub for progress. | +| `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceProgressNotifier.cs` | SHARED INFRASTRUCTURE | Broadcasts persisted run progress events. | +| `/srv/repos/PlotDirector/PlotLine/Services/PersistedStoryIntelligenceRunner.cs` | IMPORT PIPELINE / STORY INTELLIGENCE | AI execution, persistence, progress, Story Memory, Character Intelligence. | +| `/srv/repos/PlotDirector/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md` | STORY INTELLIGENCE | Boundary prompt; explicitly excludes story entities/observations. | +| `/srv/repos/PlotDirector/PlotLine/Docs/AI/Scene-Prompt-V2.md` | STORY INTELLIGENCE / VISUALISATION COUPLED | Main scene schema includes many fields consumed by import and visualisation, including `characterAppearance`. | +| `/srv/repos/PlotDirector/PlotLine/Services/StoryMemoryServices.cs` | STORY INTELLIGENCE / DO NOT REMOVE UNTIL DECOUPLED | Durable derived scene memory and illustration assignment/demand. | +| `/srv/repos/PlotDirector/PlotLine/Data/StoryMemoryRepository.cs` | STORY INTELLIGENCE / SHARED INFRASTRUCTURE | Persistence for Story Memory. Note: currently uses inline SQL. | +| `/srv/repos/PlotDirector/PlotLine/Services/IllustrationLibraryServices.cs` | SHARED INFRASTRUCTURE | Reusable image library and image generation worker integration. | +| `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs` | VISUALISATION COUPLED / UNCERTAIN | Older project-level assignment/demand based on visualisation DTOs. | +| `/srv/repos/PlotDirector/PlotLine/Data/StoryIntelligenceIllustrationMatchingRepository.cs` | VISUALISATION COUPLED / UNCERTAIN | Project-level visualisation assignment/demand tables. | +| SQL `114`, `115`, `117`, `121`, `127`, `137`, `142`, `144`, `145`, `146`, `147` | IMPORT/STORY INTELLIGENCE | Persisted runs, result storage, pipeline state, Story Memory, character context, safe image policy. | +| SQL `135`, `136`, `138`, `139`, `140`, `141` | IMAGE/VISUALISATION COUPLED | Illustration library, matching, demand, audit. | + +## 5. Event Catalogue + +| Event/update | Example | Source | Persisted? | AI derived? | Required elsewhere? | +|---|---|---|---|---|---| +| Import started | "Story Intelligence has started reading this chapter." | `PersistedStoryIntelligenceRunner.PublishAsync` | Run progress fields yes | No | Yes, production progress | +| Chapter started | "PlotDirector is finding the scene boundaries..." | Runner before Chapter Structure call | Run progress fields yes | No | Yes | +| Scene boundaries detected | "Detected 4 suggested scenes." | Runner after Chapter Structure response | Yes, chapter result and run counts | Yes | Yes | +| Scene analysis started | "Reading scene 2." | Runner before Scene Intelligence call | Run progress fields yes | No | Yes | +| Scene analysis completed | "Scene 2 analysed." | Runner after scene persistence | Yes, scene result and run counts | Yes | Yes | +| Latest scene summary | Scene short summary | Scene prompt `summary.short` | Yes in `ParsedJson`; SignalR transient copy | Yes | Yes, review/progress | +| Recent discoveries | "Possible character found: Beth" | Runner derives from parsed characters/locations/assets | SignalR transient; source persisted | Yes source, No derivation | Nice-to-have | +| Failure | "Scene 2 needs attention." | Runner catch blocks | Yes | Sometimes | Yes | +| Cancellation | "Analysis was cancelled." | Runner cancellation path | Yes | No | Yes | +| Completion | "Chapter analysis complete." | Runner completion path | Yes | No | Yes | +| Snapshot change token | hash of run/result state | Visualisation snapshot service | No | No | Visualisation only | +| Current scene/window | latest persisted scene result | Visualisation snapshot service | No | No | Visualisation only | +| Character node | name, role, relevance, weight | Visualisation snapshot service from scene characters plus story memory | No as DTO; source persisted | Mostly yes | Review/import uses source, not node | +| Location node | active location image/text | Snapshot service from scene locations/setting | No as DTO; source persisted | Mostly yes | Review/import uses source | +| Asset state | important assets, often not rendered stage nodes | Snapshot service from scene assets | No as DTO; source persisted | Mostly yes | Review/import uses source | +| Relationship lines | labels/states/weights | Snapshot service from scene relationships and visual memory inference | Some source persisted; inferred visual memory no | Mixed | Explicit relationships yes; hard-coded visual inference no | +| Knowledge threads | questions and knowledge changes | Snapshot service from scene JSON | No as DTO; source persisted | Yes | Knowledge import uses knowledge changes | +| Observations | title/detail evidence cards | Snapshot service from `scene.Observations` | No as DTO; source persisted | Yes | Unclear/no strong current consumer outside visualisation | +| Image demand status | demand recorded/queued/satisfied | Story Memory or old matching service | Yes | No direct text AI; may trigger image generation later | Images useful to visualisation, maybe library | +| Image generated | library item status generated | Illustration worker/library | Yes | Image model call | Shared library, but demand originates from SI memory/visualisation coupling | + +## 6. Visualisation-Driven Intelligence + +Information already required by PlotDirector: + +- Scene boundaries, scene summaries, scene references, and detected scene counts are core import outputs. +- Characters, aliases, locations, assets, relationships, knowledge changes, metrics, source limits, and continuity signals are used by review/import services. +- Character aliases and known-character context are used by the prompt to reduce identity drift. +- Character appearance has a downstream consumer: Story Memory and illustration matching. It is not required for scene creation itself, but it is required by the current image assignment/demand system. + +Information that appears primarily visualisation-oriented: + +- `StoryIntelligenceExperience*` DTOs, image resolution diagnostics, current visual scene window, display weights, stage labels, timeline labels, feed items, SVG connection labels, replay indexes, panel counts, and change tokens. +- Snapshot-service in-memory `StoryMemory` used to carry characters, point-of-view, and relationships through the visual replay. +- Hard-coded/thematic inference in `StoryIntelligenceVisualisationSnapshotService`, including Beth/Grace/Elen family inference and summary-language inference. This is not the same as canonical relationship import. +- `scene.Observations` as a displayed "Story observations" panel. It is generated by Scene Prompt V2 and persisted in scene JSON, but source searches found no strong downstream consumer outside visualisation/Story Memory evidence extraction. + +Information with uncertain value: + +- Character appearance and deterministic appearance inference. It is useful only if reusable illustrations remain part of Story Intelligence or future passive visualisation. +- Durable Story Memory entities. They may become useful for future context/intent or import review, but current normal import review services still primarily read persisted scene results rather than requiring Story Memory. + +## 7. AI Cost Attribution + +| AI operation | Required by Import | Required by SI | Required by Visualisation | Could remove? | +|---|---:|---:|---:|---| +| Chapter Structure prompt | Yes | Yes | No | No | +| Scene Intelligence prompt per scene | Yes | Yes | Partially: it includes fields the visualisation displays | Not wholesale | +| JSON repair retry | Yes for reliability | Yes | No | No, unless schema/output complexity is reduced enough to lower failures | +| Character Intelligence profile update | No for basic import | Yes/current architecture | No direct | Possibly defer or make opt-in | +| Story Memory deterministic processing | No for basic import | Partially/current architecture | Yes for image/visual continuity | Decouple before removing | +| Illustration library image generation | No for manuscript import | No for textual SI | Yes/partially for visual experience | Yes for simplified import | +| Development replay/rebuild | No | No | Yes | Yes, keep as offline/admin-only if needed | + +Answer: the visualisation partially increases AI cost. Opening the snapshot endpoint does not itself call the text AI. But the current scene prompt asks for `characterAppearance` specifically for illustration matching, Story Memory can queue image demand during import, and development rebuild can create demand records. If automatic image generation is enabled and approval is not required, those demands can lead to image-generation calls. + +## 8. Evidence and Observation System + +The "Beth pours tea" style evidence comes from Scene Prompt V2 and the persisted `SceneIntelligenceScene` JSON, not from a separate visualisation prompt. Relevant fields include: + +- `characters[].actions`, `characters[].notes` +- `relationships[].relationshipSignal`, `relationships[].evidence` +- `knowledgeChanges[].evidence` +- `questionsRaised[].evidence` +- `observations[].description`, `observations[].predicate`, `observations[].objectName`, `observations[].evidence` +- `characterAppearance[].evidence` + +Representation: + +- Models are in `/srv/repos/PlotDirector/PlotLine/Models/StoryIntelligencePersistenceModels.cs` and `/srv/repos/PlotDirector/PlotLine/Models/StoryIntelligenceImportPreviewModels.cs`. +- Scene results are stored by `/srv/repos/PlotDirector/PlotLine/Data/StoryIntelligenceResultRepository.cs` via `dbo.StoryIntelligenceSceneResult_Save`. +- The visualisation reads `ParsedJson`, deserializes `SceneIntelligenceScene`, and projects observations into `StoryIntelligenceExperienceTextItem`. + +Consumers: + +- Character/location/asset/relationship/knowledge import services read scene JSON. +- Story Memory reads characters, appearance, locations, assets, and relationships. +- The visualisation explicitly renders observations. No clear normal user-facing consumer of `scene.Observations` was identified outside the visualisation-style experience. + +Classification: + +- CORE STORY INTELLIGENCE: scene summary, characters, relationships, locations, assets, knowledge changes, source limits. +- USEFUL DERIVED INTELLIGENCE: known-character aliases, deterministic appearance facts if image matching stays, Story Memory identities if future context/intent uses them. +- VISUALISATION TELEMETRY: visual DTOs, panel state, change token, replay index, display weights, latest-discovery phrasing, activity feed. +- UNUSED / AI EXHAUST: `observations` appears likely unless a downstream product consumer is added. + +## 9. Image-Generation Lifecycle + +Current route: + +```text +Scene result saved + -> StoryMemoryService.ProcessSceneResultAsync + -> Upsert durable memory characters/locations/assets/relationships + -> ResolveIllustrationsAsync + -> match approved/generated illustration library items + -> if none, upsert demand + -> if QueueDemand enabled, create/queue planned library item + -> IllustrationGenerationWorker claims queued item + -> OpenAI image generation + -> uploaded file persisted to illustration library + -> future snapshots/replays can reuse assignment/library item +``` + +Findings: + +- Existing library images are checked first. Story Memory filters active, non-archived, non-fallback, generated/approved items and scores hard compatibility before queueing demand. +- Duplicate demand is limited by `ImportSessionID + EntityType + ArchetypeKey`; alias drift can still create separate canonical identity keys if alias resolution has not merged them. +- Image demands are tied to Story Memory temporary/import-session entities, not normal canonical Character records. +- The generated image is stored in the Story Intelligence illustration library, not automatically assigned to the canonical `Characters` table or normal character gallery/avatar. +- Subsequent chapters in the same import can reuse Story Memory assignments and library items. +- Subsequent imports can reuse the global library item if compatible, but per-import Story Memory assignments are separate. +- Failed/partial imports can leave generated or queued library items and demand records because the image library is independent of final import commit. +- The visualisation itself benefits from images, but normal manuscript import does not require generated images. + +Every route that can incur image generation cost: + +- Story Memory during the active runner, when `AutomaticImageGenerationDuringImportEnabled == true`, `RequireImageGenerationApproval == false`, and caps allow queueing. +- Development replay/rebuild path if called with `queueDemand=true`, followed by the image worker. +- Admin illustration library/starter/regeneration workflows outside import. +- Older `StoryIntelligenceIllustrationMatchingService` can upsert planned items/queue demand from visualisation DTOs; it should be treated as visualisation-coupled until proven otherwise. + +## 10. SignalR and Reusable Infrastructure + +Preserve: + +- `/srv/repos/PlotDirector/PlotLine/Hubs/StoryIntelligenceHub.cs` +- `/srv/repos/PlotDirector/PlotLine/Services/StoryIntelligenceProgressNotifier.cs` +- `/srv/repos/PlotDirector/PlotLine/wwwroot/js/story-intelligence-progress.js` +- `StoryIntelligenceRunProgressEvent` + +These are not obsolete visualisation code. They power the production progress screen and are a natural fit for the simplified unattended import. + +The full visualisation does not use SignalR directly; it polls read-only snapshot endpoints. The onboarding/progress UI uses SignalR and should remain as lightweight progress infrastructure. + +## 11. Preservation Inventory + +PRESERVE AS ACTIVE INFRASTRUCTURE: + +- Persisted run/result pipeline, background worker, queue/claim/complete/fail/cancel stored procedures. +- SignalR hub/notifier and production progress page. +- Story Intelligence review/import services for scenes, characters, locations, assets, relationships, and knowledge. +- Email queue infrastructure, although no completion email for Story Intelligence was found. +- Illustration library infrastructure if retained for user/admin image workflows. + +PRESERVE FOR FUTURE VISUALISATION: + +- Full visual design ideas from `StoryIntelligenceExperience.cshtml`, `story-intelligence-experience-prototype.css`, and `story-intelligence-experience-prototype.js`. +- Read-only snapshot endpoint pattern. +- Replay controls and ability to reconstruct from persisted results. +- Timeline/ribbon, relationship-line drawing, text fallback cards, insight detail panels, and diagnostics. +- Static fallback prototype assets. + +SAFE TO REMOVE after documentation and route cleanup: + +- Development-only simulation data and static prototype images, if no future visual replay is planned. +- Replay link from active onboarding, if the full visualisation is removed from the workflow. +- Visualisation-specific DTOs and snapshot endpoints, after confirming no external client uses them. + +DO NOT REMOVE UNTIL DECOUPLED: + +- Story Memory processing from the active runner, because it is currently intertwined with illustration assignment/demand and durable derived state. +- `characterAppearance` in the scene prompt until image matching/Story Memory decisions are made. +- Old project-level `StoryIntelligenceIllustrationMatchingService` and repository until references and tables are audited for production use. +- Any SQL tables/procs in the 135-147 range without mapping them to current consumers. + +## 12. Future Visualisation Boundary + +A future visualisation should subscribe only to: + +- `ImportSessionStatusChanged` +- `ChapterRunQueued` +- `ChapterRunStarted` +- `ChapterRunCompleted` +- `SceneResultPersisted` +- `ReviewStageAvailable` +- `ImportFailed` +- `ImportCompleted` +- `ImageAssignmentAvailable` only when an image exists naturally + +Each event should contain IDs, counts, status, display-safe summary, and links to persisted records. It should not contain prompt-only display demands. Visualisation should load data through read-only queries against persisted scene results, canonical import previews, and existing image assignments. + +The future visualisation must not: + +- Add prompt fields. +- Trigger Story Memory rebuild. +- Queue image demand. +- Persist visual-only inferred relationships. +- Request JSON repair. +- Cause additional AI calls. + +## 13. Simplified Unattended-Import Requirements + +Already exists: + +- Durable pending/running/completed/failed runs. +- Background processing independent of the browser. +- Persisted progress/status fields. +- Cancellation. +- Production progress page that says users can leave and return later. +- Email queue infrastructure and user email addresses. + +Missing or incomplete: + +- Story Intelligence completion email was not found. +- A single authoritative import-session completion event/worker action should queue email once. +- Production UX should return users directly to review/import results, not development replay. +- AI/image cost gates should be explicit before queueing text/image work. +- Story Memory and image demand should be optional/deferred for unattended import. +- Progress should rely on persisted pipeline state, not in-browser visual state. + +## 14. Screenshots and References + +- Existing design reference: `/srv/repos/PlotDirector/PlotLine/Docs/Features/PlotDirector_Story_Intelligence_Visualisation_Design.png` +- Experience design document: `/srv/repos/PlotDirector/PlotLine/Docs/Features/PlotDirector_Story_Intelligence_Experience_Design_v2.md` +- Active progress page: `/srv/repos/PlotDirector/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml` +- Full experience page: `/srv/repos/PlotDirector/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml` +- Full experience CSS: `/srv/repos/PlotDirector/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css` +- Full experience JS: `/srv/repos/PlotDirector/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js` + +## 15. Known Good Design Ideas Worth Preserving + +- "Living story view" as a stage, not a table. +- Progress ring plus plain status numbers. +- Character/location prominence based on relevance and point of view. +- Relationship lines with visual tone for support/conflict/uncertainty. +- Scene ribbon and timeline to show motion through the manuscript. +- Text-only evidence cards when image confidence is low. +- Replay from persisted results. +- Compact diagnostic panel for engineering trust. +- Respect for reduced motion. +- "Return to Import" route back to the actual workflow. + +## Recommended Subsequent Task + +Do not remove anything yet. The next implementation task should: + +1. Remove the development replay link from the active onboarding flow or hide it behind an admin/development flag. +2. Add a completion-email path driven by persisted import-session completion. +3. Disable automatic image demand during normal import by default and require explicit user/admin approval before image generation. +4. Split Story Memory into two modes: minimal domain memory required by import/context, and optional visual/image memory. +5. Trim Scene Prompt V2 so `observations` and `characterAppearance` are included only when a downstream product feature genuinely consumes them. +6. Replace visualisation DTO dependencies in image matching with domain DTOs, or retire the old project-level matching service. +7. Keep a passive snapshot/replay prototype that reads persisted results only. diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/docs/story-intelligence/visualisation-archive/runtime-source/StoryIntelligenceExperience.cshtml similarity index 100% rename from PlotLine/Views/Development/StoryIntelligenceExperience.cshtml rename to docs/story-intelligence/visualisation-archive/runtime-source/StoryIntelligenceExperience.cshtml diff --git a/PlotLine/Services/StoryIntelligenceExperiencePrototypeData.cs b/docs/story-intelligence/visualisation-archive/runtime-source/StoryIntelligenceExperiencePrototypeData.cs similarity index 100% rename from PlotLine/Services/StoryIntelligenceExperiencePrototypeData.cs rename to docs/story-intelligence/visualisation-archive/runtime-source/StoryIntelligenceExperiencePrototypeData.cs diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/docs/story-intelligence/visualisation-archive/runtime-source/StoryIntelligenceExperiencePrototypeViewModels.cs similarity index 100% rename from PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs rename to docs/story-intelligence/visualisation-archive/runtime-source/StoryIntelligenceExperiencePrototypeViewModels.cs diff --git a/PlotLine/Views/Development/StoryIntelligenceIllustrationDiagnostics.cshtml b/docs/story-intelligence/visualisation-archive/runtime-source/StoryIntelligenceIllustrationDiagnostics.cshtml similarity index 100% rename from PlotLine/Views/Development/StoryIntelligenceIllustrationDiagnostics.cshtml rename to docs/story-intelligence/visualisation-archive/runtime-source/StoryIntelligenceIllustrationDiagnostics.cshtml diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bag-wallet.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-bag-wallet.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-bag-wallet.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-bag-wallet.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bench.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-bench.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-bench.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-bench.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-book.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-book.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-book.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-book.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-bottle.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-bottle.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-bottle.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-bottle.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-clock.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-clock.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-clock.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-clock.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-document.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-document.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-document.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-document.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-envelope.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-envelope.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-envelope.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-envelope.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-environment.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-environment.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-environment.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-environment.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-generic.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-generic.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-generic.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-generic.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-keys.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-keys.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-keys.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-keys.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-letter.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-letter.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-letter.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-letter.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-notebook.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-notebook.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-notebook.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-notebook.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-parcel.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-parcel.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-parcel.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-parcel.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-phone.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-phone.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-phone.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-phone.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-photo.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-photo.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-photo.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-photo.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-red-tr6.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-red-tr6.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-red-tr6.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-red-tr6.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-rucksack.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-rucksack.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-rucksack.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-rucksack.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/asset-traffic-sign.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-traffic-sign.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/asset-traffic-sign.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/asset-traffic-sign.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/character-beth.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/character-beth.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/character-beth.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/character-beth.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/character-graham.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/character-graham.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/character-graham.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/character-graham.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/character-group.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/character-group.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/character-group.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/character-group.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/character-maggie.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/character-maggie.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/character-maggie.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/character-maggie.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/character-rosie.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/character-rosie.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/character-rosie.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/character-rosie.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-bathroom.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-bathroom.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-bathroom.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-bathroom.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-building.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-building.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-building.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-building.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-car-park.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-car-park.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-car-park.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-car-park.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-doweries.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-doweries.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-doweries.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-doweries.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-driving-test-centre.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-driving-test-centre.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-driving-test-centre.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-driving-test-centre.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-flat.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-flat.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-flat.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-flat.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-garage-workshop.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-garage-workshop.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-garage-workshop.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-garage-workshop.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-kitchen.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-kitchen.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-kitchen.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-kitchen.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-laundry.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-laundry.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-laundry.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-laundry.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-road.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-road.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-road.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-road.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-stairwell.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-stairwell.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-stairwell.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-stairwell.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-vehicle-interior.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-vehicle-interior.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-vehicle-interior.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-vehicle-interior.svg diff --git a/PlotLine/wwwroot/images/story-intelligence/prototype/location-waiting-room.svg b/docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-waiting-room.svg similarity index 100% rename from PlotLine/wwwroot/images/story-intelligence/prototype/location-waiting-room.svg rename to docs/story-intelligence/visualisation-archive/runtime-source/prototype-assets/location-waiting-room.svg diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/docs/story-intelligence/visualisation-archive/runtime-source/story-intelligence-experience-prototype.css similarity index 100% rename from PlotLine/wwwroot/css/story-intelligence-experience-prototype.css rename to docs/story-intelligence/visualisation-archive/runtime-source/story-intelligence-experience-prototype.css diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/docs/story-intelligence/visualisation-archive/runtime-source/story-intelligence-experience-prototype.js similarity index 100% rename from PlotLine/wwwroot/js/story-intelligence-experience-prototype.js rename to docs/story-intelligence/visualisation-archive/runtime-source/story-intelligence-experience-prototype.js