Phase 20AP – Asset Intelligence Review and Import

This commit is contained in:
Nick Beckley 2026-07-09 21:02:41 +01:00
parent 55cded0b40
commit 11de81abed
18 changed files with 1556 additions and 15 deletions

View File

@ -15,7 +15,10 @@ var tests = new (string Name, Action Test)[]
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames), ("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases), ("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases),
("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations), ("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations),
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants) ("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants)
}; };
foreach (var test in tests) foreach (var test in tests)
@ -198,6 +201,51 @@ static bool InvokePrivateLocationFilter(string methodName, string name)
return method!.Invoke(null, [name]) is true; return method!.Invoke(null, [name]) is true;
} }
static void AssetFilteringRejectsGenericObjects()
{
Assert(!IsAssetNameCandidate("chair"), "Chair should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("table"), "Table should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("street"), "Street should not be treated as a story asset.");
Assert(!IsAssetNameCandidate("car park"), "Car park should not be treated as a story asset.");
}
static void AssetFilteringPreservesStoryAssets()
{
Assert(IsAssetNameCandidate("TR6"), "TR6 should be treated as a story asset.");
Assert(IsAssetNameCandidate("red notebook"), "Red notebook should be treated as a story asset.");
Assert(IsAssetNameCandidate("passport"), "Passport should be treated as a story asset.");
Assert(IsAssetNameCandidate("car keys"), "Car keys should be treated as a story asset.");
Assert(IsAssetNameCandidate("driving licence"), "Driving licence should be treated as a story asset.");
}
static void AssetCanonicalKeysMergeTrivialVariants()
{
Assert(AssetCanonicalKey("red notebook") == AssetCanonicalKey("the notebook"), "Notebook variants should merge.");
Assert(AssetCanonicalKey("photo") == AssetCanonicalKey("photograph"), "Photo should fold into photograph.");
Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge.");
}
static bool IsAssetNameCandidate(string name)
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name);
static string AssetCanonicalKey(string name)
{
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
"CanonicalAssetKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "CanonicalAssetKey was not found.");
return (string)method!.Invoke(null, [name])!;
}
static bool InvokePrivateAssetFilter(string methodName, string name)
{
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
methodName,
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, $"{methodName} was not found.");
return method!.Invoke(null, [name]) is true;
}
static JsonSerializerOptions JsonOptions() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {

View File

@ -75,6 +75,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return target.Route switch return target.Route switch
{ {
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }),
_ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID }) _ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID })
@ -181,6 +182,35 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
: View(model); : View(model);
} }
[HttpGet("story-intelligence/assets")]
public async Task<IActionResult> StoryIntelligenceAssets(Guid batchId)
{
var model = await storyIntelligence.GetProgressAsync(batchId);
if (model is null)
{
return NotFound();
}
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
}
if (!model.AllCommitted)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
}
if (!model.PipelineDashboard.CharacterStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
}
return !model.PipelineDashboard.LocationStageComplete
? RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId })
: View(model);
}
[HttpPost("story-intelligence/cancel")] [HttpPost("story-intelligence/cancel")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> CancelStoryIntelligence(Guid batchId) public async Task<IActionResult> CancelStoryIntelligence(Guid batchId)
@ -277,6 +307,29 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return model is null ? NotFound() : View(model); return model is null ? NotFound() : View(model);
} }
[HttpPost("story-intelligence/assets")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceAssets(StoryIntelligenceAssetImportForm form)
{
var (progress, result) = await storyIntelligence.ImportAssetsAsync(form.BatchID, form);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceAssetComplete), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = form.BatchID });
}
[HttpGet("story-intelligence/assets/complete")]
public async Task<IActionResult> StoryIntelligenceAssetComplete(Guid batchId)
{
var model = await storyIntelligence.GetAssetImportResultAsync(batchId);
return model is null ? NotFound() : View(model);
}
[HttpGet("story-intelligence/complete")] [HttpGet("story-intelligence/complete")]
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId) public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
{ {
@ -296,6 +349,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
} }
if (!progress.PipelineDashboard.AssetStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
}
var model = await storyIntelligence.GetCompletionAsync(batchId); var model = await storyIntelligence.GetCompletionAsync(batchId);
return model is null ? NotFound() : View(model); return model is null ? NotFound() : View(model);
} }

View File

@ -0,0 +1,88 @@
# Phase 20AP - Asset Intelligence Review and Import
Phase 20AP adds the first implemented Asset Intelligence import stage to the Story Intelligence pipeline.
The stage uses only stored Scene Intelligence JSON from completed chapter runs. It does not make additional OpenAI calls and does not change the Scene Intelligence prompts.
## Implemented Asset Stage
After Character Review/Import and Location Review/Import, the pipeline now continues to:
- Review Assets
- Create Assets
- Complete
Resume support routes books with completed characters and locations, but incomplete assets, directly to Asset Review.
## Source Data
Asset candidates are built from:
- `assets[]` in stored Scene Intelligence results
- entity-mappable observations whose subject or object is an Asset/Object
The import does not reread the manuscript and does not send raw text back to AI.
## Candidate Filtering
The asset resolver keeps story-significant objects such as:
- TR6
- rucksack
- note
- letter
- necklace
- driving licence
- car keys
- passport
- envelope
- photograph
- map
- diary
- notebook
It suppresses generic scenery, furniture, broad locations and incidental items unless there is repeated story significance or clear asset signal.
## Duplicate and Alias Handling
Asset variants are folded into one review candidate where safe.
Examples:
- `red notebook`, `the notebook`, `old notebook` -> `Notebook`
- `photo` -> `Photograph`
- `driving license` -> `Driving licence`
The review screen supports:
- create new asset
- link to existing asset
- create separate asset
- alias/variant of another reviewed asset
- ignore
## Imported Fields
Approved assets create or link:
- `StoryAssets`
- `AssetAliases`
- `AssetEvents`
- `AssetCustodyEvents` only when owner/holder text resolves to an existing character
Asset kind is mapped to existing lookup values such as Document, Evidence, Vehicle or Physical Object. New lookup values are not created.
Scene appearances are imported as Asset Events so they appear in the Scene Inspector World panel and on Asset pages/timelines.
## Not Yet Implemented
The following remain future Story Intelligence stages:
- relationships
- knowledge
- continuity
- real timeline events
- full asset dependency modelling
- unresolved owner creation
No placeholder characters or owners are created by this phase.

View File

@ -30,6 +30,8 @@ public static class StoryIntelligencePipelineStages
public const string CharacterImport = "CharacterImport"; public const string CharacterImport = "CharacterImport";
public const string LocationReview = "LocationReview"; public const string LocationReview = "LocationReview";
public const string LocationImport = "LocationImport"; public const string LocationImport = "LocationImport";
public const string AssetReview = "AssetReview";
public const string AssetImport = "AssetImport";
public const string Complete = "Complete"; public const string Complete = "Complete";
} }

View File

@ -209,6 +209,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>(); builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>(); builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>(); builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>(); builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();

View File

@ -18,6 +18,8 @@ public interface IOnboardingStoryIntelligenceService
Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId); Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form);
Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId); Task<StoryIntelligenceLocationImportResultViewModel?> GetLocationImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form);
Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId); Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId); Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
} }
@ -30,6 +32,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceImportCommitService commits, IStoryIntelligenceImportCommitService commits,
IStoryIntelligenceCharacterImportService characterImport, IStoryIntelligenceCharacterImportService characterImport,
IStoryIntelligenceLocationImportService locationImport, IStoryIntelligenceLocationImportService locationImport,
IStoryIntelligenceAssetImportService assetImport,
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore, IOnboardingStoryIntelligenceBatchStore batchStore,
@ -263,6 +266,7 @@ public sealed class OnboardingStoryIntelligenceService(
var characterReview = await characterImport.BuildReviewAsync(batch); var characterReview = await characterImport.BuildReviewAsync(batch);
var locationReview = await locationImport.BuildReviewAsync(batch); var locationReview = await locationImport.BuildReviewAsync(batch);
var assetReview = await assetImport.BuildReviewAsync(batch);
return new StoryIntelligenceProgressViewModel return new StoryIntelligenceProgressViewModel
{ {
BatchID = batch.BatchID, BatchID = batch.BatchID,
@ -274,6 +278,7 @@ public sealed class OnboardingStoryIntelligenceService(
Chapters = chapters, Chapters = chapters,
CharacterReview = characterReview, CharacterReview = characterReview,
LocationReview = locationReview, LocationReview = locationReview,
AssetReview = assetReview,
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{ {
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
@ -283,7 +288,10 @@ public sealed class OnboardingStoryIntelligenceService(
CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete, CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete,
LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count, LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count,
LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked), LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked),
LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete,
AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count,
AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked),
AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete
} }
}; };
} }
@ -418,6 +426,40 @@ public sealed class OnboardingStoryIntelligenceService(
}; };
} }
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportAssetsAsync(Guid batchId, StoryIntelligenceAssetImportForm form)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
}
var result = await assetImport.ImportAsync(batch, form);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceAssetImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
AssetsCreated = batch.LastAssetImportResult?.AssetsCreated ?? 0,
AssetsLinked = batch.LastAssetImportResult?.AssetsLinked ?? 0,
AssetAliasesAdded = batch.LastAssetImportResult?.AssetAliasesAdded ?? 0,
AssetsIgnored = batch.LastAssetImportResult?.AssetsIgnored ?? 0,
SceneAssetEventsCreated = batch.LastAssetImportResult?.SceneAssetEventsCreated ?? 0,
OwnershipLinksCreated = batch.LastAssetImportResult?.OwnershipLinksCreated ?? 0
};
}
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId) public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
{ {
var progress = await GetProgressAsync(batchId); var progress = await GetProgressAsync(batchId);
@ -439,7 +481,9 @@ public sealed class OnboardingStoryIntelligenceService(
CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked, CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked,
CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete, CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete,
LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked, LocationsCreatedOrLinked = progress.PipelineDashboard.LocationsCreatedOrLinked,
LocationStageComplete = progress.PipelineDashboard.LocationStageComplete LocationStageComplete = progress.PipelineDashboard.LocationStageComplete,
AssetsCreatedOrLinked = progress.PipelineDashboard.AssetsCreatedOrLinked,
AssetStageComplete = progress.PipelineDashboard.AssetStageComplete
}; };
} }
@ -482,6 +526,12 @@ public sealed class OnboardingStoryIntelligenceService(
}; };
if (state.IsComplete) if (state.IsComplete)
{
batch.CharacterStageComplete = true;
batch.LocationStageComplete = true;
batch.AssetStageComplete = true;
}
else if (state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport)
{ {
batch.CharacterStageComplete = true; batch.CharacterStageComplete = true;
batch.LocationStageComplete = true; batch.LocationStageComplete = true;
@ -496,6 +546,8 @@ public sealed class OnboardingStoryIntelligenceService(
var route = state.CurrentStage switch var route = state.CurrentStage switch
{ {
StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete, StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete,
StoryIntelligencePipelineStages.AssetReview => StoryIntelligenceResumeRoutes.Assets,
StoryIntelligencePipelineStages.AssetImport => StoryIntelligenceResumeRoutes.Assets,
StoryIntelligencePipelineStages.LocationReview => StoryIntelligenceResumeRoutes.Locations, StoryIntelligencePipelineStages.LocationReview => StoryIntelligenceResumeRoutes.Locations,
StoryIntelligencePipelineStages.LocationImport => StoryIntelligenceResumeRoutes.Locations, StoryIntelligencePipelineStages.LocationImport => StoryIntelligenceResumeRoutes.Locations,
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
@ -737,10 +789,13 @@ public sealed class OnboardingStoryIntelligenceBatch
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = []; public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = []; public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = []; public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; } public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
public bool CharacterStageComplete { get; set; } public bool CharacterStageComplete { get; set; }
public bool LocationStageComplete { get; set; } public bool LocationStageComplete { get; set; }
public bool AssetStageComplete { get; set; }
} }
public sealed class OnboardingStoryIntelligenceBatchItem public sealed class OnboardingStoryIntelligenceBatchItem
@ -770,6 +825,15 @@ public sealed class OnboardingStoryIntelligenceLocationDecision
public bool CreatedOrLinked { get; init; } public bool CreatedOrLinked { get; init; }
} }
public sealed class OnboardingStoryIntelligenceAssetDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public string? AssetName { get; init; }
public int? StoryAssetID { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class StoryIntelligenceCharacterImportBatchResult public sealed class StoryIntelligenceCharacterImportBatchResult
{ {
public int CharactersCreated { get; init; } public int CharactersCreated { get; init; }
@ -789,11 +853,22 @@ public sealed class StoryIntelligenceLocationImportBatchResult
public int SceneLocationsUpdated { get; init; } public int SceneLocationsUpdated { get; init; }
} }
public sealed class StoryIntelligenceAssetImportBatchResult
{
public int AssetsCreated { get; init; }
public int AssetsLinked { get; init; }
public int AssetAliasesAdded { get; init; }
public int AssetsIgnored { get; init; }
public int SceneAssetEventsCreated { get; init; }
public int OwnershipLinksCreated { get; init; }
}
public static class StoryIntelligenceResumeRoutes public static class StoryIntelligenceResumeRoutes
{ {
public const string SceneReview = "SceneReview"; public const string SceneReview = "SceneReview";
public const string Characters = "Characters"; public const string Characters = "Characters";
public const string Locations = "Locations"; public const string Locations = "Locations";
public const string Assets = "Assets";
public const string Complete = "Complete"; public const string Complete = "Complete";
} }

View File

@ -0,0 +1,843 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceAssetImportService
{
Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form);
}
public sealed class StoryIntelligenceAssetImportService(
IStoryIntelligenceResultRepository runs,
IAssetRepository assets,
ICharacterRepository characters,
ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceAssetImportService> logger) : IStoryIntelligenceAssetImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static readonly HashSet<string> GenericObjects = CreateSet(
"chair", "chairs", "table", "tables", "wall", "walls", "floor", "floors", "window", "windows",
"door", "doors", "tree", "trees", "grass", "lamp", "lamps", "cup", "cups", "plate", "plates",
"knife and fork", "people", "person", "building", "car park", "street", "road", "room", "desk",
"bed", "sofa", "sink", "towel", "mug", "glass", "bottle", "phone", "telephone", "key", "keys",
"car", "vehicle", "coat", "dress", "shoes", "shirt", "trousers", "weather", "rain", "food", "drink");
private static readonly HashSet<string> AcceptedAssetNames = CreateSet(
"tr6", "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note",
"notebook", "red notebook", "old notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring",
"ring", "necklace", "cassette tape", "camera", "map", "diary", "watch", "driving licence",
"driving license", "licence", "license", "document", "folder", "wallet", "bank book", "scrap of paper");
private static readonly HashSet<string> StorySignals = CreateSet(
"lost", "found", "hidden", "stolen", "missing", "taken", "given", "carried", "used", "opened",
"revealed", "evidence", "clue", "important", "sought", "passport", "licence", "license", "letter",
"note", "photograph", "photo", "map", "diary", "notebook", "keys", "necklace", "ring");
private static readonly HashSet<string> AdjectivesToFold = CreateSet(
"red", "old", "new", "small", "large", "little", "anonymous", "folded", "sealed", "missing");
public async Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.AssetDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceAssetReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceAssetReviewCandidateViewModel
{
Key = candidate.Key,
AssetName = candidate.DisplayName,
ImportName = candidate.DisplayName,
Category = candidate.Category,
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
PossibleOwner = candidate.OwnerOrHolder,
ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExampleContext = candidate.Description,
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
ExistingAssetID = candidate.ExistingAssetID,
ExistingAssetName = candidate.ExistingAssetName
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form)
{
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing assets." };
}
var choices = form.Assets
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
if (choices.Count == 0)
{
if (data.Candidates.Count == 0)
{
batch.AssetStageComplete = true;
batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult();
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = "Asset review completed. No asset decisions were waiting."
};
}
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one asset to create, link or ignore." };
}
var existingIndex = await BuildAssetIndexAsync(batch.ProjectID);
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var lookupData = await assets.GetLookupsAsync(batch.ProjectID);
var created = 0;
var linkedExisting = 0;
var ignored = 0;
var aliasesAdded = 0;
var sceneEventsCreated = 0;
var ownershipLinksCreated = 0;
var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidateByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice))
{
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.Ignore, candidate.DisplayName, null, false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var requestedName = Clean(choice.ImportName);
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = forceCreateSeparate
? null
: candidate.ExistingAssetID
?? existingIndex.Find(importName)?.StoryAssetID
?? existingIndex.Find(candidate.DisplayName)?.StoryAssetID;
var assetId = matchedExistingId;
if (!assetId.HasValue)
{
assetId = await assets.SaveAssetAsync(new StoryAsset
{
ProjectID = batch.ProjectID,
AssetName = importName,
AssetKindID = MatchAssetKind(lookupData.AssetKinds, candidate),
Description = BuildAssetDescription(candidate),
Importance = 5,
CurrentStateID = MatchAssetState(lookupData.AssetStates, "Known To Exist")
});
created++;
}
else
{
linkedExisting++;
}
resolvedCandidateIds[candidate.Key] = assetId.Value;
AddDecision(batch, candidate.Key, choice.Action, importName, assetId.Value, true);
foreach (var alias in candidate.Aliases)
{
if (await TryAddAliasAsync(assetId.Value, alias, importName))
{
aliasesAdded++;
}
}
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var targetKey = Clean(choice.AliasTargetKey);
if (string.IsNullOrWhiteSpace(targetKey)
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|| !choices.TryGetValue(targetKey, out var targetChoice)
|| string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetAssetId))
{
logger.LogWarning(
"Story Intelligence asset alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
var targetName = targetCandidate.ExistingAssetName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
if (await TryAddAliasAsync(targetAssetId, candidate.DisplayName, targetName))
{
aliasesAdded++;
}
foreach (var alias in candidate.Aliases)
{
if (await TryAddAliasAsync(targetAssetId, alias, targetName))
{
aliasesAdded++;
}
}
resolvedCandidateIds[candidate.Key] = targetAssetId;
AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.Alias, candidate.DisplayName, targetAssetId, true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var assetId = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)
? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey))
: resolvedCandidateIds.GetValueOrDefault(candidate.Key);
if (assetId <= 0)
{
continue;
}
foreach (var appearance in candidate.Appearances
.Where(appearance => !appearance.AlreadyLinked)
.GroupBy(appearance => appearance.SceneID)
.Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First()))
{
await assets.SaveEventAsync(new AssetEvent
{
StoryAssetID = assetId,
SceneID = appearance.SceneID,
AssetEventTypeID = MatchAssetEventType(lookupData.AssetEventTypes, appearance.EventType),
ToStateID = MatchAssetState(lookupData.AssetStates, StateFromEventType(appearance.EventType)),
EventTitle = appearance.EventType,
EventDescription = appearance.Notes
});
sceneEventsCreated++;
var owner = Clean(appearance.OwnerOrHolder ?? candidate.OwnerOrHolder);
if (!string.IsNullOrWhiteSpace(owner)
&& characterIndex.Find(owner) is CharacterMatch ownerMatch
&& lookupData.AssetCustodyEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Has", StringComparison.OrdinalIgnoreCase)) is { } custodyType
&& lookupData.CustodyRoles.FirstOrDefault(role => string.Equals(role.RoleName, "Has Custody", StringComparison.OrdinalIgnoreCase)) is { } role)
{
await assets.SaveCustodyEventAsync(
new AssetCustodyEvent
{
StoryAssetID = assetId,
SceneID = appearance.SceneID,
AssetCustodyEventTypeID = custodyType.AssetCustodyEventTypeID,
Description = $"Initial owner/holder from Story Intelligence: {owner}"
},
owner,
[ownerMatch.CharacterID],
role.CustodyRoleID);
ownershipLinksCreated++;
}
}
}
batch.AssetStageComplete = true;
batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult
{
AssetsCreated = created,
AssetsLinked = linkedExisting,
AssetAliasesAdded = aliasesAdded,
AssetsIgnored = ignored,
SceneAssetEventsCreated = sceneEventsCreated,
OwnershipLinksCreated = ownershipLinksCreated
};
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}",
batch.BatchID,
created,
linkedExisting,
aliasesAdded,
ignored,
sceneEventsCreated,
ownershipLinksCreated);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = sceneEventsCreated,
Message = $"Assets imported. {created:N0} created, {linkedExisting:N0} linked, {aliasesAdded:N0} alias/variant(s) added, {ignored:N0} ignored, {sceneEventsCreated:N0} scene asset event(s) created."
};
}
private async Task<AssetCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var existingIndex = await BuildAssetIndexAsync(batch.ProjectID);
var groups = new Dictionary<string, AssetCandidate>(StringComparer.OrdinalIgnoreCase);
var alreadyLinked = 0;
var hasCommittedScenes = false;
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
continue;
}
hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults)
{
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
}
var existingEvents = await assets.ListEventsBySceneAsync(importedScene.SceneID);
alreadyLinked += existingEvents.Select(assetEvent => assetEvent.StoryAssetID).Distinct().Count();
AddSceneAssets(groups, existingIndex, importedScene, parsed, existingEvents);
}
}
var candidates = groups.Values
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.Where(IsVisibleAssetCandidate)
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.DisplayName)
.ToList();
return new AssetCandidateData(hasCommittedScenes, alreadyLinked, candidates);
}
private static void AddSceneAssets(
Dictionary<string, AssetCandidate> groups,
AssetIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<AssetEvent> existingEvents)
{
foreach (var asset in parsed.Assets ?? [])
{
AddAsset(groups, existingIndex, importedScene, parsed, asset, existingEvents);
}
foreach (var observation in parsed.Observations ?? [])
{
if (IsAssetEntity(observation.SubjectEntityType))
{
AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset
{
Name = observation.SubjectName,
AssetType = observation.ObservationType,
Status = observation.Predicate,
Confidence = observation.Confidence,
Notes = observation.Description
}, existingEvents);
}
if (IsAssetEntity(observation.ObjectEntityType))
{
AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset
{
Name = observation.ObjectName,
AssetType = observation.ObservationType,
Status = observation.Predicate,
MentionedOnly = true,
Confidence = observation.Confidence,
Notes = observation.Description
}, existingEvents);
}
}
}
private static void AddAsset(
Dictionary<string, AssetCandidate> groups,
AssetIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
SceneIntelligenceAsset asset,
IReadOnlyList<AssetEvent> existingEvents)
{
var cleanName = CleanAssetName(asset.Name);
if (!IsAssetNameCandidate(cleanName))
{
return;
}
var key = ResolveCandidateKey(groups, existingIndex, cleanName);
if (!groups.TryGetValue(key, out var candidate))
{
var match = existingIndex.Find(cleanName);
candidate = new AssetCandidate(key, DisplayAssetName(cleanName), match?.StoryAssetID, match?.AssetName)
{
Category = Categorise(cleanName, asset.AssetType)
};
groups[key] = candidate;
}
else if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase))
{
candidate.Aliases.Add(cleanName);
}
candidate.Confidence = Max(candidate.Confidence, asset.Confidence);
candidate.Description ??= Clean(asset.Notes);
candidate.OwnerOrHolder ??= Clean(asset.OwnerOrHolder);
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName);
candidate.Appearances.Add(new AssetAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
EventTypeFromAsset(asset),
asset.OwnerOrHolder,
asset.Notes,
asset.Confidence,
candidate.ExistingAssetID.HasValue && existingEvents.Any(existing => existing.StoryAssetID == candidate.ExistingAssetID.Value)));
}
private async Task<AssetIndex> BuildAssetIndexAsync(int projectId)
{
var index = new AssetIndex();
foreach (var asset in await assets.ListAssetsAsync(projectId))
{
index.Add(asset.AssetName, asset.StoryAssetID, asset.AssetName);
foreach (var alias in await assets.ListAliasesAsync(asset.StoryAssetID))
{
index.Add(alias.Alias, asset.StoryAssetID, asset.AssetName);
}
}
return index;
}
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
{
var index = new CharacterIndex();
foreach (var character in await characters.ListCharactersAsync(projectId))
{
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
{
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
}
}
return index;
}
private async Task<bool> TryAddAliasAsync(int storyAssetId, string alias, string primaryName)
{
var cleanAlias = Clean(alias);
if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var existingAliases = await assets.ListAliasesAsync(storyAssetId);
if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
await assets.AddAliasAsync(storyAssetId, cleanAlias, null);
return true;
}
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
{
return rangeMatch;
}
return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null;
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
{
if (string.IsNullOrWhiteSpace(result.ParsedJson))
{
return null;
}
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(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<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
}
return null;
}
private static int MatchAssetKind(IReadOnlyList<AssetKind> kinds, AssetCandidate candidate)
=> kinds.FirstOrDefault(kind =>
candidate.Category.Contains(kind.KindName, StringComparison.OrdinalIgnoreCase)
|| kind.KindName.Contains(candidate.Category, StringComparison.OrdinalIgnoreCase))?.AssetKindID
?? kinds.FirstOrDefault(kind => string.Equals(kind.KindName, "Physical Object", StringComparison.OrdinalIgnoreCase))?.AssetKindID
?? kinds.First().AssetKindID;
private static int MatchAssetEventType(IReadOnlyList<AssetEventType> eventTypes, string eventType)
=> eventTypes.FirstOrDefault(type => string.Equals(type.TypeName, eventType, StringComparison.OrdinalIgnoreCase))?.AssetEventTypeID
?? eventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Mentioned", StringComparison.OrdinalIgnoreCase))?.AssetEventTypeID
?? eventTypes.First().AssetEventTypeID;
private static int? MatchAssetState(IReadOnlyList<AssetState> states, string? stateName)
=> string.IsNullOrWhiteSpace(stateName)
? null
: states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.AssetStateID;
private static string StateFromEventType(string eventType)
=> eventType switch
{
"First Mentioned" => "Known To Exist",
"Mentioned" => "Mentioned",
"Found" => "Found",
"Given" => "Possessed",
"Taken" => "Possessed",
"Lost" => "Lost",
"Hidden" => "Hidden",
"Used" => "Used",
"Opened" => "Opened",
"Revealed" => "Revealed",
"Investigated" => "Investigated",
_ => "Known To Exist"
};
private static string BuildAssetDescription(AssetCandidate candidate)
{
var lines = new List<string>();
if (!string.IsNullOrWhiteSpace(candidate.OwnerOrHolder))
{
lines.Add($"Possible owner/holder from Story Intelligence: {candidate.OwnerOrHolder}");
}
if (!string.IsNullOrWhiteSpace(candidate.Description))
{
lines.Add(candidate.Description);
}
return string.Join(Environment.NewLine, lines);
}
private static bool IsVisibleAssetCandidate(AssetCandidate candidate)
{
if (IsAcceptedStoryAssetName(candidate.DisplayName))
{
return true;
}
var sceneCount = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count();
var signalText = $"{candidate.DisplayName} {candidate.Category} {candidate.Description} {candidate.OwnerOrHolder} {string.Join(' ', candidate.Appearances.Select(a => a.Notes))}";
return sceneCount >= 2 && ContainsStoryAssetSignal(signalText);
}
private static bool IsAssetNameCandidate(string? name)
{
var clean = CleanAssetName(name);
if (string.IsNullOrWhiteSpace(clean) || clean.Length < 2 || !clean.Any(char.IsLetter))
{
return false;
}
if (IsAcceptedStoryAssetName(clean))
{
return true;
}
var normalised = Normalise(clean);
return !GenericObjects.Contains(normalised) && !GenericObjects.Contains(RemoveLeadingArticle(normalised));
}
private static bool IsAcceptedStoryAssetName(string? name)
{
var normalised = Normalise(CleanAssetName(name));
var withoutArticle = RemoveLeadingArticle(normalised);
return AcceptedAssetNames.Contains(normalised)
|| AcceptedAssetNames.Contains(withoutArticle)
|| normalised.Contains("tr6", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains("'s car", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains("s car", StringComparison.OrdinalIgnoreCase);
}
private static bool ContainsStoryAssetSignal(string value)
=> StorySignals.Any(signal => value.Contains(signal, StringComparison.OrdinalIgnoreCase));
private static string EventTypeFromAsset(SceneIntelligenceAsset asset)
{
var value = $"{asset.Status} {asset.Notes}".ToLowerInvariant();
if (value.Contains("found")) return "Found";
if (value.Contains("lost")) return "Lost";
if (value.Contains("given") || value.Contains("gives")) return "Given";
if (value.Contains("taken") || value.Contains("takes")) return "Taken";
if (value.Contains("stolen")) return "Stolen";
if (value.Contains("hidden") || value.Contains("hides")) return "Hidden";
if (value.Contains("opened")) return "Opened";
if (value.Contains("used") || value.Contains("uses")) return "Used";
if (value.Contains("revealed") || value.Contains("reveals")) return "Revealed";
if (value.Contains("investigated")) return "Investigated";
return asset.MentionedOnly == true ? "Mentioned" : "First Mentioned";
}
private static string Categorise(string name, string? assetType)
{
var value = $"{name} {assetType}".ToLowerInvariant();
if (value.Contains("passport") || value.Contains("licence") || value.Contains("license") || value.Contains("letter") || value.Contains("note") || value.Contains("map") || value.Contains("diary") || value.Contains("notebook") || value.Contains("document"))
{
return "Document";
}
if (value.Contains("photo") || value.Contains("evidence") || value.Contains("clue"))
{
return "Evidence";
}
if (value.Contains("car") || value.Contains("tr6") || value.Contains("vehicle"))
{
return "Vehicle";
}
if (value.Contains("necklace") || value.Contains("ring") || value.Contains("watch"))
{
return "Physical Object";
}
return string.IsNullOrWhiteSpace(assetType) ? "Physical Object" : Clean(assetType);
}
private static string ResolveCandidateKey(
IReadOnlyDictionary<string, AssetCandidate> groups,
AssetIndex existingIndex,
string name)
{
var existingMatch = existingIndex.Find(name);
if (existingMatch is not null)
{
return CanonicalAssetKey(existingMatch.AssetName);
}
var canonical = CanonicalAssetKey(name);
return groups.ContainsKey(canonical) ? canonical : canonical;
}
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName)
{
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? $"Scene {scene.SceneNumber:g}: {assetName}"
: $"Scene {scene.SceneNumber:g}: {summary}";
}
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? assetName, int? storyAssetId, bool createdOrLinked)
{
batch.AssetDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
batch.AssetDecisions.Add(new OnboardingStoryIntelligenceAssetDecision
{
Key = key,
Action = action,
AssetName = assetName,
StoryAssetID = storyAssetId,
CreatedOrLinked = createdOrLinked
});
}
private static bool IsAssetEntity(string? entityType)
=> string.Equals(Clean(entityType), "Asset", StringComparison.OrdinalIgnoreCase)
|| string.Equals(Clean(entityType), "Object", StringComparison.OrdinalIgnoreCase);
private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
private static string ConfidenceBand(decimal? confidence)
=> confidence switch
{
>= 0.75m => "High",
>= 0.45m => "Medium",
null => "Unknown",
_ => "Low"
};
private static string DisplayAssetName(string name)
{
var clean = CleanAssetName(name);
return string.IsNullOrWhiteSpace(clean) ? clean : char.ToUpperInvariant(clean[0]) + clean[1..];
}
private static string CanonicalAssetKey(string name)
{
var clean = RemoveLeadingArticle(Normalise(CleanAssetName(name)));
var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
while (words.Count > 1 && AdjectivesToFold.Contains(words[0]))
{
words.RemoveAt(0);
}
if (words.Count == 2 && words[1].Equals("keys", StringComparison.OrdinalIgnoreCase))
{
return "keys";
}
if (clean is "photo")
{
return "photograph";
}
if (clean is "driving license")
{
return "driving licence";
}
return string.Join(' ', words);
}
private static string CleanAssetName(string? value)
=> Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\'');
private static string RemoveLeadingArticle(string value)
=> value.StartsWith("the ", StringComparison.OrdinalIgnoreCase)
? value[4..]
: value.StartsWith("a ", StringComparison.OrdinalIgnoreCase)
? value[2..]
: value.StartsWith("an ", StringComparison.OrdinalIgnoreCase)
? value[3..]
: value;
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
private sealed class AssetIndex
{
private readonly Dictionary<string, AssetMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int storyAssetId, string assetName)
{
var key = CanonicalAssetKey(name ?? string.Empty);
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new AssetMatch(storyAssetId, assetName));
}
}
public AssetMatch? Find(string name)
=> byName.TryGetValue(CanonicalAssetKey(name), out var match) ? match : null;
}
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int characterId, string characterName)
{
var key = Normalise(name);
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
}
}
public CharacterMatch? Find(string name)
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
}
private sealed record AssetMatch(int StoryAssetID, string AssetName);
private sealed record CharacterMatch(int CharacterID, string CharacterName);
private sealed class AssetCandidate(string key, string displayName, int? existingAssetId, string? existingAssetName)
{
public string Key { get; } = key;
public string DisplayName { get; } = displayName;
public int? ExistingAssetID { get; } = existingAssetId;
public string? ExistingAssetName { get; } = existingAssetName;
public string Category { get; set; } = "Physical Object";
public string? OwnerOrHolder { get; set; }
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<AssetAppearanceImport> Appearances { get; } = [];
public decimal? Confidence { get; set; }
public string? Description { get; set; }
public string? FirstAppearanceNote { get; set; }
}
private sealed record AssetAppearanceImport(
int SceneID,
decimal SceneNumber,
string SceneTitle,
string EventType,
string? OwnerOrHolder,
string? Notes,
decimal? Confidence,
bool AlreadyLinked);
private sealed record AssetCandidateData(
bool HasCommittedScenes,
int AlreadyLinkedCount,
IReadOnlyList<AssetCandidate> Candidates);
}

View File

@ -47,11 +47,11 @@ public sealed class StoryIntelligenceLocationImportService(
private static readonly HashSet<string> StoryLocationSignals = CreateSet( private static readonly HashSet<string> StoryLocationSignals = CreateSet(
"trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub", "trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub",
"cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate"); "cafe", "shop", "factory", "centre", "center", "hospital", "station", "hotel", "estate", "doweries");
private static readonly HashSet<string> ConjunctionLocationWords = CreateSet( private static readonly HashSet<string> ConjunctionLocationWords = CreateSet(
"shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden", "shop", "staff room", "house", "garden", "road", "bridge", "kitchen", "hallway", "front garden",
"rear garden", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street", "car park"); "rear garden", "front", "rear", "room", "office", "garage", "bathroom", "bedroom", "corridor", "street", "car park");
private static readonly Regex AddressPattern = new(@"^\d+\s+\p{L}", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex AddressPattern = new(@"^\d+\s+\p{L}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex NamedRoadPattern = new(@"\b(street|road|lane|avenue|drive|way|close|crescent|square|place)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex NamedRoadPattern = new(@"\b(street|road|lane|avenue|drive|way|close|crescent|square|place)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);

View File

@ -11,6 +11,7 @@ public interface IStoryIntelligencePipelineStateService
Task RecordSceneImportAsync(int projectId, int bookId, int runId); Task RecordSceneImportAsync(int projectId, int bookId, int runId);
Task RecordCharacterImportAsync(int projectId, int bookId); Task RecordCharacterImportAsync(int projectId, int bookId);
Task RecordLocationImportAsync(int projectId, int bookId); Task RecordLocationImportAsync(int projectId, int bookId);
Task RecordAssetImportAsync(int projectId, int bookId);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
} }
@ -96,8 +97,23 @@ public sealed class StoryIntelligencePipelineStateService(
{ {
ProjectID = projectId, ProjectID = projectId,
BookID = bookId, BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete, CurrentStage = StoryIntelligencePipelineStages.AssetReview,
LastCompletedStage = StoryIntelligencePipelineStages.LocationImport, LastCompletedStage = StoryIntelligencePipelineStages.LocationImport,
CurrentReviewStage = StoryIntelligencePipelineStages.AssetReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null,
LastRunID = null
});
}
public async Task RecordAssetImportAsync(int projectId, int bookId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete,
LastCompletedStage = StoryIntelligencePipelineStages.AssetImport,
CurrentReviewStage = null, CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.Complete, Status = StoryIntelligencePipelineStatuses.Complete,
CompletedUtc = DateTime.UtcNow, CompletedUtc = DateTime.UtcNow,

View File

@ -0,0 +1,36 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines', N'U') IS NOT NULL
BEGIN
IF EXISTS
(
SELECT 1
FROM sys.check_constraints
WHERE name = N'CK_StoryIntelligenceBookPipelines_CurrentStage'
AND parent_object_id = OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines')
)
BEGIN
ALTER TABLE dbo.StoryIntelligenceBookPipelines
DROP CONSTRAINT CK_StoryIntelligenceBookPipelines_CurrentStage;
END;
ALTER TABLE dbo.StoryIntelligenceBookPipelines
ADD CONSTRAINT CK_StoryIntelligenceBookPipelines_CurrentStage
CHECK (CurrentStage IN
(
N'Chapters',
N'SceneReview',
N'SceneImport',
N'CharacterReview',
N'CharacterImport',
N'LocationReview',
N'LocationImport',
N'AssetReview',
N'AssetImport',
N'Complete'
));
END;
GO

View File

@ -144,6 +144,7 @@ public sealed class StoryIntelligenceProgressViewModel
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = []; public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new(); public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new(); public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new(); public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
public int ChapterCount => Chapters.Count; public int ChapterCount => Chapters.Count;
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete); public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
@ -160,6 +161,7 @@ public sealed class StoryIntelligenceProgressViewModel
public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit); public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit);
public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0; public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0;
public bool HasLocationsToReview => LocationReview.Candidates.Count > 0; public bool HasLocationsToReview => LocationReview.Candidates.Count > 0;
public bool HasAssetsToReview => AssetReview.Candidates.Count > 0;
} }
public sealed class StoryIntelligencePipelineDashboardViewModel public sealed class StoryIntelligencePipelineDashboardViewModel
@ -172,6 +174,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
public int LocationsIdentified { get; init; } public int LocationsIdentified { get; init; }
public int LocationsCreatedOrLinked { get; init; } public int LocationsCreatedOrLinked { get; init; }
public bool LocationStageComplete { get; init; } public bool LocationStageComplete { get; init; }
public int AssetsIdentified { get; init; }
public int AssetsCreatedOrLinked { get; init; }
public bool AssetStageComplete { get; init; }
} }
public sealed class StoryIntelligenceCharacterReviewViewModel public sealed class StoryIntelligenceCharacterReviewViewModel
@ -268,6 +273,55 @@ public static class StoryIntelligenceLocationImportActions
public const string Alias = "Alias"; public const string Alias = "Alias";
} }
public sealed class StoryIntelligenceAssetReviewViewModel
{
public bool CanImport { get; init; }
public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; }
public IReadOnlyList<StoryIntelligenceAssetReviewCandidateViewModel> Candidates { get; init; } = [];
}
public sealed class StoryIntelligenceAssetReviewCandidateViewModel
{
public string Key { get; init; } = string.Empty;
public string AssetName { get; init; } = string.Empty;
public string ImportName { get; init; } = string.Empty;
public string Category { get; init; } = "Story asset";
public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown";
public string? PossibleOwner { get; init; }
public string? ExampleFirstAppearance { get; init; }
public string? ExampleContext { get; init; }
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
public int? ExistingAssetID { get; init; }
public string? ExistingAssetName { get; init; }
public bool IsExistingMatch => ExistingAssetID.HasValue;
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
}
public sealed class StoryIntelligenceAssetImportForm
{
public Guid BatchID { get; set; }
public List<StoryIntelligenceAssetImportChoiceForm> Assets { get; set; } = [];
}
public sealed class StoryIntelligenceAssetImportChoiceForm
{
public string Key { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceAssetImportActions.Approve;
public string? ImportName { get; set; }
public string? AliasTargetKey { get; set; }
}
public static class StoryIntelligenceAssetImportActions
{
public const string Approve = "Approve";
public const string Ignore = "Ignore";
public const string CreateSeparate = "CreateSeparate";
public const string Alias = "Alias";
}
public sealed class StoryIntelligenceCompletionViewModel public sealed class StoryIntelligenceCompletionViewModel
{ {
public StoryIntelligenceJobProgress Job { get; init; } = new(); public StoryIntelligenceJobProgress Job { get; init; } = new();
@ -283,6 +337,8 @@ public sealed class StoryIntelligenceCompletionViewModel
public bool CharacterStageComplete { get; init; } public bool CharacterStageComplete { get; init; }
public int LocationsCreatedOrLinked { get; init; } public int LocationsCreatedOrLinked { get; init; }
public bool LocationStageComplete { get; init; } public bool LocationStageComplete { get; init; }
public int AssetsCreatedOrLinked { get; init; }
public bool AssetStageComplete { get; init; }
} }
public sealed class StoryIntelligenceCharacterImportResultViewModel public sealed class StoryIntelligenceCharacterImportResultViewModel
@ -310,6 +366,19 @@ public sealed class StoryIntelligenceLocationImportResultViewModel
public int SceneLocationsUpdated { get; init; } public int SceneLocationsUpdated { get; init; }
} }
public sealed class StoryIntelligenceAssetImportResultViewModel
{
public Guid BatchID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int AssetsCreated { get; init; }
public int AssetsLinked { get; init; }
public int AssetAliasesAdded { get; init; }
public int AssetsIgnored { get; init; }
public int SceneAssetEventsCreated { get; init; }
public int OwnershipLinksCreated { get; init; }
}
public sealed class StoryIntelligenceOnboardingChapterViewModel public sealed class StoryIntelligenceOnboardingChapterViewModel
{ {
public string TemporaryChapterKey { get; init; } = string.Empty; public string TemporaryChapterKey { get; init; } = string.Empty;

View File

@ -0,0 +1,57 @@
@model StoryIntelligenceAssetImportResultViewModel
@{
ViewData["Title"] = "Assets created";
}
<section class="onboarding-shell" aria-labelledby="story-asset-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Create Assets" })" />
<div class="onboarding-copy">
<p class="eyebrow">Build Story Database</p>
<div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span>
<h1 id="story-asset-complete-title">Assets created</h1>
</div>
<p>PlotDirector updated your story database from the approved asset decisions. Later Story Intelligence stages will continue from here.</p>
</div>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Assets created</span>
<strong>@Model.AssetsCreated.ToString("N0")</strong>
</div>
<div>
<span>Assets linked</span>
<strong>@Model.AssetsLinked.ToString("N0")</strong>
</div>
<div>
<span>Aliases added</span>
<strong>@Model.AssetAliasesAdded.ToString("N0")</strong>
</div>
<div>
<span>Assets ignored</span>
<strong>@Model.AssetsIgnored.ToString("N0")</strong>
</div>
<div>
<span>Scene World panels updated</span>
<strong>@Model.SceneAssetEventsCreated.ToString("N0")</strong>
</div>
<div>
<span>Ownership links</span>
<strong>@Model.OwnershipLinksCreated.ToString("N0")</strong>
</div>
</div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Timeline Events</strong><span>Coming Soon</span></article>
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Back to assets</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
</div>
</div>
</section>

View File

@ -0,0 +1,236 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review assets";
}
<section class="onboarding-shell" aria-labelledby="story-asset-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Assets" })" />
<div class="onboarding-copy">
<p class="eyebrow">Review Story Intelligence</p>
<h1 id="story-asset-title">Review Assets</h1>
<p>Approve the important objects PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" />
<section class="story-review-chapter-list" aria-label="Asset review">
@if (Model.AssetReview.Candidates.Count == 0)
{
<div class="story-review-note">
<strong>No asset decisions are waiting.</strong>
<p class="mb-0">Assets are already up to date, or there were no story-significant asset suggestions ready to import.</p>
</div>
<div class="story-future-stage-grid">
@FutureStage("Review Relationships")
@FutureStage("Review Knowledge")
@FutureStage("Review Continuity")
@FutureStage("Timeline Events")
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a>
<form asp-action="ImportStoryIntelligenceAssets" method="post">
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<button class="btn btn-primary" type="submit">Continue</button>
</form>
</div>
}
else
{
<div class="story-review-card-actions">
<button class="btn btn-outline-primary btn-sm" type="button" data-asset-bulk="approve-all">Approve all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="approve-selected">Approve selected</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="ignore-selected">Ignore selected</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-asset-bulk="collapse">Collapse all</button>
</div>
<form asp-action="ImportStoryIntelligenceAssets" method="post" data-asset-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.AssetReview.Candidates.Count; i++)
{
var candidate = Model.AssetReview.Candidates[i];
<details class="story-character-card" open data-asset-card>
<summary>
<input type="checkbox" checked data-asset-selected aria-label="Select @candidate.AssetName" />
<span>
<strong>@candidate.AssetName</strong>
<small>@candidate.Category · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
</span>
<em>@candidate.Confidence</em>
</summary>
<input type="hidden" name="Assets[@i].Key" value="@candidate.Key" />
<div class="story-character-card__body" data-asset-key="@candidate.Key" data-asset-name="@candidate.AssetName">
<dl>
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
<div><dt>Possible owner</dt><dd>@Display(candidate.PossibleOwner)</dd></div>
<div><dt>Existing match</dt><dd>@(candidate.ExistingAssetName ?? "None")</dd></div>
</dl>
@if (candidate.PossibleAliases.Any())
{
<div class="story-review-note">
<strong>Possible aliases</strong>
<p>@string.Join(", ", candidate.PossibleAliases)</p>
</div>
}
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
<div class="story-review-note">
<strong>Scene context</strong>
<p>@candidate.ExampleContext</p>
</div>
}
@if (candidate.IsExistingMatch)
{
<div class="story-review-note">
<strong>Possible existing asset</strong>
<p>@candidate.AssetName may already be @candidate.ExistingAssetName. Choose whether to link them or create a separate asset.</p>
</div>
}
<fieldset class="story-character-actions">
<legend>Decision</legend>
<label>
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Approve" checked data-asset-action />
@(candidate.IsExistingMatch ? "Link to existing" : "Create new asset")
</label>
@if (candidate.IsExistingMatch)
{
<label>
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.CreateSeparate" data-asset-action />
Create separate asset
</label>
}
<label>
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Alias" data-asset-action />
Alias/variant of another asset
</label>
<label>
<input type="radio" name="Assets[@i].Action" value="@StoryIntelligenceAssetImportActions.Ignore" data-asset-action />
Ignore
</label>
</fieldset>
<div data-asset-import-name-panel>
<label class="form-label" for="asset-import-name-@i">Import name</label>
<input id="asset-import-name-@i" class="form-control" name="Assets[@i].ImportName" value="@candidate.ImportName" />
</div>
<div data-asset-alias-panel hidden>
<label class="form-label" for="asset-alias-target-@i">Alias/variant target</label>
<select id="asset-alias-target-@i" class="form-select" name="Assets[@i].AliasTargetKey" data-asset-alias-target>
<option value="">Choose asset...</option>
@foreach (var target in Model.AssetReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.AssetName@(target.IsExistingMatch ? $" -> {target.ExistingAssetName}" : string.Empty)</option>
}
</select>
</div>
</div>
</details>
}
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a>
<button class="btn btn-primary" type="submit">Create assets</button>
</div>
</form>
}
</section>
</div>
</section>
@section Scripts {
<script>
(() => {
const form = document.querySelector("[data-asset-review-form]");
if (!form) return;
const cards = () => Array.from(form.querySelectorAll("[data-asset-card]"));
const setAction = (card, action) => {
const input = card.querySelector(`[data-asset-action][value="${action}"]`);
if (input) {
input.checked = true;
updateCard(card);
}
};
const actionFor = (card) => card.querySelector("[data-asset-action]:checked")?.value || "@StoryIntelligenceAssetImportActions.Approve";
const isCanonicalTarget = (card) => {
const action = actionFor(card);
return action !== "@StoryIntelligenceAssetImportActions.Ignore" && action !== "@StoryIntelligenceAssetImportActions.Alias";
};
const updateAliasTargets = () => {
const allCards = cards();
for (const card of allCards) {
const body = card.querySelector("[data-asset-key]");
const ownKey = body?.getAttribute("data-asset-key") || "";
const select = card.querySelector("[data-asset-alias-target]");
if (!select) continue;
const previous = select.value;
for (const option of Array.from(select.options)) {
if (!option.value) continue;
const targetCard = allCards.find(item => item.querySelector("[data-asset-key]")?.getAttribute("data-asset-key") === option.value);
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
}
if (select.selectedOptions[0]?.disabled) {
select.value = "";
} else {
select.value = previous;
}
}
};
const updateCard = (card) => {
const action = actionFor(card);
const namePanel = card.querySelector("[data-asset-import-name-panel]");
const aliasPanel = card.querySelector("[data-asset-alias-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceAssetImportActions.Ignore" || action === "@StoryIntelligenceAssetImportActions.Alias";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceAssetImportActions.Alias";
updateAliasTargets();
};
document.querySelectorAll("[data-asset-bulk]").forEach(button => {
button.addEventListener("click", () => {
const action = button.getAttribute("data-asset-bulk");
const selectedCards = cards().filter(card => card.querySelector("[data-asset-selected]")?.checked);
if (action === "approve-all") cards().forEach(card => setAction(card, "@StoryIntelligenceAssetImportActions.Approve"));
if (action === "approve-selected") selectedCards.forEach(card => setAction(card, "@StoryIntelligenceAssetImportActions.Approve"));
if (action === "ignore-selected") selectedCards.forEach(card => setAction(card, "@StoryIntelligenceAssetImportActions.Ignore"));
if (action === "expand") cards().forEach(card => card.open = true);
if (action === "collapse") cards().forEach(card => card.open = false);
});
});
form.querySelectorAll("[data-asset-action]").forEach(input => {
input.addEventListener("change", () => updateCard(input.closest("[data-asset-card]")));
});
cards().forEach(updateCard);
form.addEventListener("submit", () => {
cards()
.filter(card => !card.querySelector("[data-asset-selected]")?.checked)
.forEach(card => setAction(card, "@StoryIntelligenceAssetImportActions.Ignore"));
});
})();
</script>
}
@functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>");
}

View File

@ -28,6 +28,14 @@
<span>Characters created / linked</span> <span>Characters created / linked</span>
<strong>@Model.CharactersCreatedOrLinked.ToString("N0")</strong> <strong>@Model.CharactersCreatedOrLinked.ToString("N0")</strong>
</div> </div>
<div>
<span>Locations created / linked</span>
<strong>@Model.LocationsCreatedOrLinked.ToString("N0")</strong>
</div>
<div>
<span>Assets created / linked</span>
<strong>@Model.AssetsCreatedOrLinked.ToString("N0")</strong>
</div>
<div> <div>
<span>Book</span> <span>Book</span>
<strong>@Model.BookTitle</strong> <strong>@Model.BookTitle</strong>
@ -35,10 +43,10 @@
</div> </div>
<div class="story-future-stage-grid"> <div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Locations</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Assets</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Timeline Events</strong><span>Coming Soon</span></article>
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">

View File

@ -39,7 +39,7 @@
</div> </div>
<div class="story-future-stage-grid"> <div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Assets</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Assets</strong><span>Next</span></article>
<article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Relationships</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
@ -47,7 +47,7 @@
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Back to locations</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a> <a class="btn btn-primary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Review assets</a>
</div> </div>
</div> </div>
</section> </section>

View File

@ -31,14 +31,14 @@
<p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p> <p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p>
</div> </div>
<div class="story-future-stage-grid"> <div class="story-future-stage-grid">
@FutureStage("Review Assets") <article class="story-future-stage"><strong>Review Assets</strong><span>Next</span></article>
@FutureStage("Review Relationships") @FutureStage("Review Relationships")
@FutureStage("Review Knowledge") @FutureStage("Review Knowledge")
@FutureStage("Review Continuity") @FutureStage("Review Continuity")
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceCharacters" asp-route-batchId="@Model.BatchID">Back to characters</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a> <a class="btn btn-primary" asp-action="StoryIntelligenceAssets" asp-route-batchId="@Model.BatchID">Continue</a>
</div> </div>
} }
else else

View File

@ -5,7 +5,7 @@
new { Name = "Phase 1", Label = "Prepare manuscript", Stages = new[] { "Connect Word Companion", "Scan Manuscript", "Review Chapters" } }, new { Name = "Phase 1", Label = "Prepare manuscript", Stages = new[] { "Connect Word Companion", "Scan Manuscript", "Review Chapters" } },
new { Name = "Phase 2", Label = "Analyse manuscript", Stages = new[] { "Analyse Manuscript" } }, new { Name = "Phase 2", Label = "Analyse manuscript", Stages = new[] { "Analyse Manuscript" } },
new { Name = "Phase 3", Label = "Review Story Intelligence", Stages = new[] { "Review Scenes", "Review Characters", "Review Locations", "Review Assets", "Review Relationships", "Review Knowledge" } }, new { Name = "Phase 3", Label = "Review Story Intelligence", Stages = new[] { "Review Scenes", "Review Characters", "Review Locations", "Review Assets", "Review Relationships", "Review Knowledge" } },
new { Name = "Phase 4", Label = "Build Story Database", Stages = new[] { "Create Scenes", "Create Characters", "Complete" } } new { Name = "Phase 4", Label = "Build Story Database", Stages = new[] { "Create Scenes", "Create Characters", "Create Locations", "Create Assets", "Complete" } }
}; };
var flatStages = phases.SelectMany(phase => phase.Stages).ToList(); var flatStages = phases.SelectMany(phase => phase.Stages).ToList();
var currentIndex = Math.Max(0, flatStages.FindIndex(stage => string.Equals(stage, Model.CurrentStage, StringComparison.OrdinalIgnoreCase))); var currentIndex = Math.Max(0, flatStages.FindIndex(stage => string.Equals(stage, Model.CurrentStage, StringComparison.OrdinalIgnoreCase)));

View File

@ -26,8 +26,12 @@
<strong>@(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")</strong> <strong>@(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")</strong>
</div> </div>
<div> <div>
<span>Assets</span> <span>Assets identified</span>
<strong>Coming next</strong> <strong>@Model.AssetsIdentified.ToString("N0")</strong>
</div>
<div>
<span>Assets created / linked</span>
<strong>@(Model.AssetStageComplete ? $"Done: {Model.AssetsCreatedOrLinked:N0}" : "In review")</strong>
</div> </div>
<div> <div>
<span>Relationships</span> <span>Relationships</span>