Phase 20AP – Asset Intelligence Review and Import
This commit is contained in:
parent
55cded0b40
commit
11de81abed
@ -15,7 +15,10 @@ var tests = new (string Name, Action Test)[]
|
||||
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
|
||||
("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases),
|
||||
("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)
|
||||
@ -198,6 +201,51 @@ static bool InvokePrivateLocationFilter(string methodName, string name)
|
||||
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()
|
||||
=> new()
|
||||
{
|
||||
|
||||
@ -75,6 +75,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
return target.Route switch
|
||||
{
|
||||
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.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }),
|
||||
_ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID })
|
||||
@ -181,6 +182,35 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
: 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")]
|
||||
[ValidateAntiForgeryToken]
|
||||
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);
|
||||
}
|
||||
|
||||
[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")]
|
||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
||||
{
|
||||
@ -296,6 +349,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
|
||||
}
|
||||
|
||||
if (!progress.PipelineDashboard.AssetStageComplete)
|
||||
{
|
||||
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
|
||||
}
|
||||
|
||||
var model = await storyIntelligence.GetCompletionAsync(batchId);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
88
PlotLine/Docs/StoryIntelligence_Phase20AP_AssetImport.md
Normal file
88
PlotLine/Docs/StoryIntelligence_Phase20AP_AssetImport.md
Normal 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.
|
||||
@ -30,6 +30,8 @@ public static class StoryIntelligencePipelineStages
|
||||
public const string CharacterImport = "CharacterImport";
|
||||
public const string LocationReview = "LocationReview";
|
||||
public const string LocationImport = "LocationImport";
|
||||
public const string AssetReview = "AssetReview";
|
||||
public const string AssetImport = "AssetImport";
|
||||
public const string Complete = "Complete";
|
||||
}
|
||||
|
||||
|
||||
@ -209,6 +209,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||
|
||||
@ -18,6 +18,8 @@ public interface IOnboardingStoryIntelligenceService
|
||||
Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId);
|
||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportLocationsAsync(Guid batchId, StoryIntelligenceLocationImportForm form);
|
||||
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<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
||||
}
|
||||
@ -30,6 +32,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
IStoryIntelligenceImportCommitService commits,
|
||||
IStoryIntelligenceCharacterImportService characterImport,
|
||||
IStoryIntelligenceLocationImportService locationImport,
|
||||
IStoryIntelligenceAssetImportService assetImport,
|
||||
IStoryIntelligencePipelineStateService pipelineState,
|
||||
IStoryIntelligenceClient client,
|
||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||
@ -263,6 +266,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
|
||||
var characterReview = await characterImport.BuildReviewAsync(batch);
|
||||
var locationReview = await locationImport.BuildReviewAsync(batch);
|
||||
var assetReview = await assetImport.BuildReviewAsync(batch);
|
||||
return new StoryIntelligenceProgressViewModel
|
||||
{
|
||||
BatchID = batch.BatchID,
|
||||
@ -274,6 +278,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
Chapters = chapters,
|
||||
CharacterReview = characterReview,
|
||||
LocationReview = locationReview,
|
||||
AssetReview = assetReview,
|
||||
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
||||
{
|
||||
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
||||
@ -283,7 +288,10 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete,
|
||||
LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count,
|
||||
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)
|
||||
{
|
||||
var progress = await GetProgressAsync(batchId);
|
||||
@ -439,7 +481,9 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked,
|
||||
CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete,
|
||||
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)
|
||||
{
|
||||
batch.CharacterStageComplete = true;
|
||||
batch.LocationStageComplete = true;
|
||||
batch.AssetStageComplete = true;
|
||||
}
|
||||
else if (state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport)
|
||||
{
|
||||
batch.CharacterStageComplete = true;
|
||||
batch.LocationStageComplete = true;
|
||||
@ -496,6 +546,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
var route = state.CurrentStage switch
|
||||
{
|
||||
StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete,
|
||||
StoryIntelligencePipelineStages.AssetReview => StoryIntelligenceResumeRoutes.Assets,
|
||||
StoryIntelligencePipelineStages.AssetImport => StoryIntelligenceResumeRoutes.Assets,
|
||||
StoryIntelligencePipelineStages.LocationReview => StoryIntelligenceResumeRoutes.Locations,
|
||||
StoryIntelligencePipelineStages.LocationImport => StoryIntelligenceResumeRoutes.Locations,
|
||||
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
|
||||
@ -737,10 +789,13 @@ public sealed class OnboardingStoryIntelligenceBatch
|
||||
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
||||
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
||||
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
|
||||
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
|
||||
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
|
||||
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
|
||||
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
|
||||
public bool CharacterStageComplete { get; set; }
|
||||
public bool LocationStageComplete { get; set; }
|
||||
public bool AssetStageComplete { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnboardingStoryIntelligenceBatchItem
|
||||
@ -770,6 +825,15 @@ public sealed class OnboardingStoryIntelligenceLocationDecision
|
||||
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 int CharactersCreated { get; init; }
|
||||
@ -789,11 +853,22 @@ public sealed class StoryIntelligenceLocationImportBatchResult
|
||||
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 const string SceneReview = "SceneReview";
|
||||
public const string Characters = "Characters";
|
||||
public const string Locations = "Locations";
|
||||
public const string Assets = "Assets";
|
||||
public const string Complete = "Complete";
|
||||
}
|
||||
|
||||
|
||||
843
PlotLine/Services/StoryIntelligenceAssetImportService.cs
Normal file
843
PlotLine/Services/StoryIntelligenceAssetImportService.cs
Normal 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);
|
||||
}
|
||||
@ -47,11 +47,11 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
|
||||
private static readonly HashSet<string> StoryLocationSignals = CreateSet(
|
||||
"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(
|
||||
"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 NamedRoadPattern = new(@"\b(street|road|lane|avenue|drive|way|close|crescent|square|place)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
@ -11,6 +11,7 @@ public interface IStoryIntelligencePipelineStateService
|
||||
Task RecordSceneImportAsync(int projectId, int bookId, int runId);
|
||||
Task RecordCharacterImportAsync(int projectId, int bookId);
|
||||
Task RecordLocationImportAsync(int projectId, int bookId);
|
||||
Task RecordAssetImportAsync(int projectId, int bookId);
|
||||
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
||||
}
|
||||
|
||||
@ -96,8 +97,23 @@ public sealed class StoryIntelligencePipelineStateService(
|
||||
{
|
||||
ProjectID = projectId,
|
||||
BookID = bookId,
|
||||
CurrentStage = StoryIntelligencePipelineStages.Complete,
|
||||
CurrentStage = StoryIntelligencePipelineStages.AssetReview,
|
||||
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,
|
||||
Status = StoryIntelligencePipelineStatuses.Complete,
|
||||
CompletedUtc = DateTime.UtcNow,
|
||||
|
||||
36
PlotLine/Sql/130_Phase20AP_AssetReviewPipelineStages.sql
Normal file
36
PlotLine/Sql/130_Phase20AP_AssetReviewPipelineStages.sql
Normal 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
|
||||
@ -144,6 +144,7 @@ public sealed class StoryIntelligenceProgressViewModel
|
||||
public IReadOnlyList<StoryIntelligenceOnboardingChapterViewModel> Chapters { get; init; } = [];
|
||||
public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new();
|
||||
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
|
||||
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
|
||||
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
|
||||
public int ChapterCount => Chapters.Count;
|
||||
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 HasCharactersToReview => CharacterReview.Candidates.Count > 0;
|
||||
public bool HasLocationsToReview => LocationReview.Candidates.Count > 0;
|
||||
public bool HasAssetsToReview => AssetReview.Candidates.Count > 0;
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligencePipelineDashboardViewModel
|
||||
@ -172,6 +174,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
|
||||
public int LocationsIdentified { get; init; }
|
||||
public int LocationsCreatedOrLinked { 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
|
||||
@ -268,6 +273,55 @@ public static class StoryIntelligenceLocationImportActions
|
||||
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 StoryIntelligenceJobProgress Job { get; init; } = new();
|
||||
@ -283,6 +337,8 @@ public sealed class StoryIntelligenceCompletionViewModel
|
||||
public bool CharacterStageComplete { get; init; }
|
||||
public int LocationsCreatedOrLinked { get; init; }
|
||||
public bool LocationStageComplete { get; init; }
|
||||
public int AssetsCreatedOrLinked { get; init; }
|
||||
public bool AssetStageComplete { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceCharacterImportResultViewModel
|
||||
@ -310,6 +366,19 @@ public sealed class StoryIntelligenceLocationImportResultViewModel
|
||||
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 string TemporaryChapterKey { get; init; } = string.Empty;
|
||||
|
||||
@ -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">✓</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>
|
||||
236
PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml
Normal file
236
PlotLine/Views/Onboarding/StoryIntelligenceAssets.cshtml
Normal 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>");
|
||||
}
|
||||
@ -28,6 +28,14 @@
|
||||
<span>Characters created / linked</span>
|
||||
<strong>@Model.CharactersCreatedOrLinked.ToString("N0")</strong>
|
||||
</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>
|
||||
<span>Book</span>
|
||||
<strong>@Model.BookTitle</strong>
|
||||
@ -35,10 +43,10 @@
|
||||
</div>
|
||||
|
||||
<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 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">
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
</div>
|
||||
|
||||
<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 Knowledge</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">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@ -31,14 +31,14 @@
|
||||
<p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p>
|
||||
</div>
|
||||
<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 Knowledge")
|
||||
@FutureStage("Review Continuity")
|
||||
</div>
|
||||
<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-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>
|
||||
}
|
||||
else
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
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 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 currentIndex = Math.Max(0, flatStages.FindIndex(stage => string.Equals(stage, Model.CurrentStage, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
@ -26,8 +26,12 @@
|
||||
<strong>@(Model.LocationStageComplete ? $"Done: {Model.LocationsCreatedOrLinked:N0}" : "In review")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Assets</span>
|
||||
<strong>Coming next</strong>
|
||||
<span>Assets identified</span>
|
||||
<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>
|
||||
<span>Relationships</span>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user